计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能领域(2+X)
学 号
班 级
学 生 徐辉
指 导 教 师 郑贵滨
计算机科学与技术学院
2023年5月
本文以分析简单的入门级程序hello为切入口,从计算机系统的视角分析一个程序的一生。高级语言编写的程序经过预处理、汇编、编译、链接等过程,生成了可执行目标文件,本文将按顺序对这些过程进行分析,并且还将从进程管理、存储管理方面描述更为详细的处理过程与功能。本文将《深入理解计算机系统》全书内容融会贯通,帮助深入理解计算机系统,了解系统如何通过硬件和系统软件的交织、共同协作以达到运行应用程序的最终目的。
关键词:计算机系统;预处理;编译;汇编;链接;进程管理;存储管理;
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 30 -
6.3 Hello的fork进程创建过程... - 31 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 34 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 35 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 35 -
7.5 三级Cache支持下的物理内存访问... - 36 -
7.6 hello进程fork时的内存映射... - 37 -
7.7 hello进程execve时的内存映射... - 38 -
第1章 概述
1.1 Hello简介
1.1.1 P2P(From program to process):从程序到进程。
Hello的一生和其他所以计算机程序一样,首先需要被程序猿用C,JAVA,Python等高级程序语言编写成程序,然后在计算机系统中需要经过预处理、编译、汇编、链接等一系列的复杂动作才可以生成一个可执行目标文件。在shell中启动,调用fork函数创建进程,调用execve函数加载并运行Hello,通过内存映射,分配空间等让Hello与其他进程并发进行,到这里Hello就顺利变成了进程。
1.1.2 020 (From Zero to Zero):从0到0
Hello没有被运行的时候,不占用任何内存。我们打开Shell通过输入./hello,使操作系统调用execve加载属于Hello的进程,为其分配虚拟内存空间(此时Hello占用一定的内存)。运行结束后,Hello进程被回收,占用的内存被释放。这就是Hello的020过程。
1.2 环境与工具
硬件:
设备名称 LAPTOP-1OIGPUED
处理器 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz 2.30 GHz
机带 RAM 16.0 GB (15.8 GB 可用)
设备 ID E9E159A8-DAE3-4E91-AE15-6CAB756F06DF
产品 ID 00342-36219-69992-AAOEM
操作系统:
Windows10 Ubuntu18.04
软件:
Visual Studio Code
1.3 中间结果
文件名称 | 作用 |
hello.c | C语言源代码 |
hello.i | 源代码在预处理后产生的文件 |
hello.s | 汇编语言文件 |
hello.o | 可重定位目标文件 |
hello | 二进制可执行文件 |
hello.txt | 可重定位文件hello.o的反汇编语言文件 |
1.4 本章小结
本章简述了Hello的P2P以及020过程,介绍了编写本论文使用的软硬件环境和开发与调试工具,同时列出了本实验得到的中间结果文件及其作用。
第2章 预处理
2.1 预处理的概念与作用
2.2在Ubuntu下预处理的命令
在程序设计领域,预处理是指在程序编译之前对源代码进行预处理的过程。预处理器是一种特殊的软件程序,用于处理源代码中以“#”开头的预处理指令。预处理器会将这些指令转换为其他代码,这些代码会在编译期间被编译器处理。
预处理的作用包括:
1. 宏定义:预处理器可以定义宏,宏是一种代码片段,可以在程序中多次使用。预处理器会将宏展开为实际的代码,并且可以传递参数。
2. 文件包含:预处理器可以包含其他文件,将这些文件的代码插入到当前文件中。这样可以避免重复编写相同的代码。
3. 条件编译:预处理器可以根据条件编译指令选择性地编译代码。这对于不同平台、不同编译器或不同配置的程序非常有用。
4. 编译优化:预处理器可以为编译器提供优化信息,例如告诉编译器哪些函数是纯函数,可以进行常量折叠等优化。
总之,预处理器可以在编译期间对源代码进行优化和处理,使程序更加高效、易于维护和可移植。
预处理命令:gcc hello.c -E -o hello.i
图表 1 预处理的结果
2.3 Hello的预处理结果解析
预处理后生成的.i文件的代码量大,原来的hello.c文件的源代码语句出现在.i文件的最后。原因是预处理器会处理源代码中以 # 开头的预处理指令,例如#include,#define等,将它们替换成实际的代码或者声明。
文件最开头是预处理器自己生成的信息,如源文件的名称等,便于后续的编译、优化和调试。
图表 2 预处理得到的.i文件
接下来是头文件stdio.h、unistd.h以及stdlib.h依次展开。预处理器在Linux默认的环境变量下寻找stdio.h等文件,将其添加至.i文件中,并将#define的符号进行替换(.i文件中会使用大量的#ifdef和#ifndef等条件编译语句)
图表 3 预处理得到的.i文件
.i文件的最后是hello.c文件的原语句(除去#后面的部分)
图表 4 预处理得到的.i文件
2.4 本章小结
本章介绍了预处理的概念和作用,提供了Ubuntu下预处理的指令,对预处理结果hello.i文本文件进行了详细解析,加深了对预处理的理解。(以前只是知道预处理的基础知识,实际上没有打开过.i文件,这次自己研究了.i文件,印象深刻!)
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念:把代码转化为汇编指令的过程,汇编指令只是CPU相关的,与高级程序语言无关。编译器(cc1)将预处理过的文本文件hello.i 翻译转换成汇编文本文件hello.s。
3.1.2 编译的作用:
1. 语法检查:编译器将对高级语言代码进行语法检查,以确保代码符合语言规范。
2. 语义分析:编译器将对代码进行语义分析,以确保代码的逻辑正确性,并且能够在计算机上正确运行。
3. 生成中间代码:编译器将高级语言代码转换为中间代码,这些代码通常是一种低级语言,比高级语言更接近计算机指令。
4. 优化中间代码:编译器将对中间代码进行优化,以提高程序的性能和效率。
5. 生成汇编代码:编译器将中间代码转换为汇编代码,这些代码是一种人类可读的低级语言,它将被汇编器转换为机器码。
3.2 在Ubuntu下编译的命令
编译指令:gcc hello.i -S -o hello.s
图表 5 编译的结果
3.3 Hello的编译结果解析
图表 6 .s文件的反汇编
3.3.1 伪指令
.s文件的第一部分是以“.”开头的伪指令,指导汇编器和链接器工作,但对于我们来说没有什么意义,可以忽略。
伪指令 | 含义 |
.file | 声明源文件 |
.text | 声明代码节 |
.section | 文件代码段 |
.rodata | 只读文件 |
.align | 数据指令地址对齐方式 |
.string | 声明字符串 |
.globl | 声明全局变量 |
.type | 声明变量类型 |
这部分可以实现C语言中常量、变量(全局/局部/静态)、表达式、类型等功能。
3.3.2 操作数指示符
汇编语言中,操作数有以下三种类型:
立即数(immediate):用来表示常数值。在ATT格式的汇编代码中,立即数的书写方式是‘$’后面跟一个用标准C表示法表示的整数,比如,$-577或$0x1F。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。
寄存器(register):它表示某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数,这些字节数分别对应于8位、16位、32位或64位。在图3-3中,我们用符号x。来表示任意寄存器a,用引用[r.]来表示它的值,这是将寄存器集合看成一个数组R,用寄存器标识符作为索引。
内存引用:它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。因为将内存看成一个很大的字节数组,我们用符号Mb[Addr]表示对存储在内存中从地址Addr开始的b个字节值的引用。为了简便,我们通常省去下标b。
如下图所示,有多种不同的寻址模式,允许不同形式的内存引用。表中底部用语法Imm(rb,ri,s)表示的是最常用的形式。这样的引用有四个组成部分:一个立即数偏移Imm,一个基址寄存器rb,一个变址寄存器ri,和一个比例因子s,这里s必须是1、2、4或者8。基址和变址寄存器都必须是64位寄存器。有效地址被计算为Imm十R[rb]十R[ri]·s。引用数组元素时,会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分。正如我们将看到的,当引用数组和结构元素时,比较复杂的寻址模式是很有用的。
图表 7 不同形式的内存引用
字符串:.LC0和.LC1作为两个字符串变量被声明。而在.LC0中出现的\347\224\250\346\263\225等是因为中文字符没有对应的ASCII码值无法直接显式显示,所以这样的字符方式显示。而且这两个字符串都在.rodata下面,所以是只读数据。随后有两句leaq指令,这个指令为加载有效地址,相当于转移操作。
3.3.3 数据传送指令
数据传送指令是将数据从一个位置复制到另一个位置的指令。操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好几条不同指令才能完成的功能。有多种不同的数据传送指令,它们或者源和目的类型不同,或者执行的转换不同,或者具有的一些副作用不同。接下来把许多不同的指令划分成指令类,每一类中的指令执行相同的操作,只不过操作数大小不同。
图表 8 数据传送指令
hello.s中使用数据传送指令较多的部分:
图表 9 hello.s中的部分指令
这部分可以实现C语言中赋值等。
3.3.4 算数和逻辑操作
下表列出常见的算数和逻辑操作,每种操作指令也限制数据的大小
图表 10 算数和逻辑操作
如hello.s中的语句addq $16, %rax
这部分可以实现C语言中算数操作(+ - * / % ++ -- 取正/负+- 复合“+=”等)和逻辑操作(逻辑&& || ! 位 & | ~ ^ 移位>> << 复合操作如 “|=” 或“<<=”等)
3.3.6 条件控制
汇编语言中,一些指令会改变条件码,如set,cmp,test等,与其他指令配合使用,可以实现C语言中的关系操作(== != > < >= <=)和控制转移等操作。
3.3.7 跳转指令
正常执行的情况下,指令按照它们出现的顺序一条一条地执行。跳转((jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号指明。以hello.s为例
jmp .L3
这个语句会使程序直接跳转到L3处执行
其余跳转指令的示例如下:
图表 11 跳转指令
这部分可以实现C语言的控制转移,goto等
3.3.8 压入和弹出栈数据
Push和pop数据传送操作可以将数据压入程序栈中,以及从程序栈中弹出数据,如下图。栈在处理过程调用中起到至关重要的作用。栈是一种数据结构,可以添加或者删除值,不过要遵循“后进先出”的原则。通过push操作把数据压入栈中,通过pop操作删除数据;它具有一个属性:弹出的值永远是最近被压入而且仍然在栈中的值。栈可以实现为一个数组,总是从数组的一端插人和删除元素。这一端被称为栈顶。在x86-64中,程序栈存放在内存中某个区域。如图3-9所示,栈向下增长,这样一来,栈顶元素的地址是所有栈中元素地址中最低的。(根据惯例,我们的栈是倒过来画的,栈“顶”在图的底部。)栈指针%rsp保存着栈顶元素的地址。
图表 12 push和pop指令
Hello.s中使用push指令的截图
这部分可以实现C语言中参数传递、局部变量、函数等
3.3.9 函数调用
call指令用来进行函数的调用。如下图所示的示例,call调用了puts函数。它会先将函数的返回地址压入运行时栈中,之后跳转到相应的函数代码段进行执行。执行结束通过ret指令返回。
3.4 本章小结
本章对简述了编译的概念和作用,给出了Linux系统下如何编译的指令,并通过hello.s文件举例说明了各个汇编指令以及C语言的数据结构和操作是如何在汇编语言中实现的。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编程序是指把汇编语言书写的程序翻译成与之等价的机器语言程序的翻译程序。这里介绍的是把hello.s汇编文件通过汇编器,输出可重定位目标文件hello.o
汇编的作用:完成从汇编语言文件到可重定位目标文件的转化过程。
4.2 在Ubuntu下汇编的命令
gcc hello.s -c -o hello.o
图表 13 汇编的结果
4.3 可重定位目标elf格式
4.3.1 elf头
hello.o是二进制可重定位目标文件,无法直接用文本编辑器打开
因此使用命令readelf -h hello.o查看ELF头,结果如下图所示:
图表 14 elf头
ELF头的开头是一个16字节的序列(Magic),这个序列描述了生成文件的系统的字的大小和字节顺序。ELF头剩下部分的信息包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的版本、机器类型、节头部表的文件偏移、以及节头部表中条目的大小和数量。
4.3.2 节头表(使用命令readelf -S hello.o查看节头)
图表 15 节头表
节头表描述了.o文件中每一个节出现的位置、大小,目标文件中的每一个节都有一个固定大小的条目。
4.3.3 符号表(使用命令readelf -s hello.o查看符号表)
图表 16 符号表
Symbol table是一个符号表,它存放于程序中定义和引用的函数和全局变量的信息。例如本程序中的getchar、puts、exit等函数
4.3.4 重定位节(使用readelf -r hello.o查看重定位节)
图表 17 重定位节
重定位节包含了在代码中使用的一些外部变量等信息,在链接的时候需要根据重定位节的信息对于某些变量符号进行修改。链接的时候链接器会根据重定位节的信息对于外部变量符号决定选择何种方法计算正确的地址,例如通过偏移量等信息计算。
本程序需要重定位的信息有:.rodata中的模式串,puts,exit,printf,atoi, sleep,getchar
4.4 Hello.o的结果解析
使用objdump -d -r hello.o > hello.txt命令对hello.o可重定位文件进行反汇编并且输出重定向至hello.txt,得到的反汇编结果如下图:
图表 18 hello.o的反汇编
hello.o反汇编文件与第三章的hello.s文件的汇编代码部分是一样的,但是反汇编文件多了一些机器代码,这里的每一条机器代码对应着一条机器指令。机器代码是计算机能理解并执行的二进制文件,机器代码与汇编代码不同的地方在于:
- 分支跳转:汇编语言中的分支跳转语句使用的是标识符(例如je .L2)来决定跳转到哪里,而机器语言中经过翻译则直接使用对应的地址来实现跳转。
- 函数调用方面:在汇编语言.s文件中,函数调用直接写上函数名。而在.o反汇编文件中,call目标地址是当前指令的下一条指令地址。这是因为hello.c中调用的函数都是共享库中的函数,需要等待链接之后才能确定响应函数的地址。因此,机器语言中,对于这种不确定地址的调用,会先将下一条指令的相对地址设置为0,然后在.rela.text节中为其添加重定位条目,等待链接时确定地址。
4.5 本章小结
本章对简述了汇编的概念和作用,给出了Linux系统下如何汇编的指令,并通过分析.o文件的elf格式文件,深入了解了可重定位目标文件;对.o文件进行反汇编,对比分析,了解机器代码与汇编代码的异同。
第5章 链接
5.1 链接的概念与作用
链接的概念:在程序设计领域,链接(Linking)指的是将多个独立的目标文件(Object File)合并成一个可执行文件(Executable File)的过程。
当我们编写一个程序时,我们通常会将程序分为多个文件进行编写,每个文件对应一个目标文件。这些目标文件包含了程序的不同部分,如函数、全局变量等。在编译过程中,编译器会将每个目标文件转换为机器语言,并生成相应的目标文件。但是,这些目标文件不能直接执行,因为它们之间存在着相互引用的关系,如函数调用等。这时,链接器就会发挥作用,将这些目标文件合并成一个可执行文件,从而完成程序的编译和链接过程。
链接的作用:
(1) 解决符号引用问题。当多个目标文件之间存在相互引用的关系时,链接器会将这些引用关系解析,从而确保程序能够正确地执行。
(2)合并代码段和数据段。在链接过程中,链接器会将多个目标文件的代码段和数据段进行合并,从而生成一个完整的可执行文件。
(3)优化程序性能。链接器还可以对程序进行优化,如去除重复的代码、压缩代码等,从而提高程序的执行效率。
总之,链接是程序编译过程中不可或缺的一个环节,它能够将多个目标文件合并成一个可执行文件,从而完成程序的编译和链接过程,确保程序能够正确地执行。
5.2 在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
图表 19 链接的结果
5.3 可执行目标文件hello的格式
5.3.1 ELF头
可执行目标文件hello的elf头和可重定位目标文件的hello.o的elf头所包含的信息种类基本相同,不同的是程序头大小和节头数量都得到了增加,并且获得了入口地址,并且文件的类型发生了变化,从REL变成了EXEC(可执行文件)
图表 20 elf头
5.3.2 节头表
相较于hello.o的节头表,hello的节头表的内容更加丰富详细。当完成链接之后程序中的一些文件就被添加进来了,每一节都有了实际地址。节头部表对hello中所有信息进行了声明,包括了大小(Size)、偏移量(Offset)、起始地址(Address)以及数据对齐方式(Align)等信息。根据始地址和大小,我们就可以计算节头部表中的每个节所在的区域。
图表 21 节头表
5.3.3 符号表
与hello.o的节头表相同的是,符号表的功能没有发生变化,所有重定位需要引用的符号都在其中说明;不同的是,main函数以外的符号也拥有了type,这证明完成了链接,其他一些文件中的符号都出现在了符号表。
图表 22 符号表
5.3.4可重定位段信息
可以发现hello.elf的重定位节与elf.txt的重定位节的名字以及内容都完全不一样,现在的所有加数都是0,证明在链接环节确实完成了各种重定位效果。
图表 23 可重定位段
5.4 hello的虚拟地址空间
用edb打开hello,由data dump部分可以看出,程序的虚拟空间从0x400000开始加载的,在0x400ff0位置结束。
图表 24 用edb打开hello
5.5 链接的重定位过程分析
在计算机程序执行时,程序中的指令和数据需要被加载到内存中才能被执行。链接时的重定位过程就是将程序中的指令和数据中涉及到内存地址的部分进行修改,使得程序能够正确地被加载到内存中并正确地执行。
在重定位过程中,需要考虑到各种因素,如程序的内存布局、符号表的处理、重定位目标的地址计算等。重定位过程是程序设计中非常重要的一环,它直接影响着程序的正确性和性能。
重定位过程包括两个主要步骤:
(1) 符号解析:根据可执行文件中的符号表,将程序中所有引用的符号解析为实际的内存地址。
(2) 地址计算:根据可执行文件中的重定位表,将程序中所有需要进行重定位的地址计算为实际的内存地址。计算公式通常是:实际地址 = 相对地址 + 基地址。
可以看到这次的汇编代码中call时的地址都变为了绝对地址,不再是最初的函数名字或相对地址。而且多了很多通过链接加进来的函数的源代码,如printf等。
图表 25 hello的反汇编
5.6 hello的执行流程
用edb打开hello可执行文件进行分析如下图,在程序中看到call后面的相关地址,找到相应地址后读出便可得到程序地址
图表 26 EDB打开hello
名称 | 地址 |
_init | 0x4004c0 |
.plt | 0x4004e0 |
puts@plt | 0x4004f0 |
printf@plt | 0x400500 |
getchar@plt | 0x400510 |
atoi@plt | 0x400520 |
exit@plt | 0x400530 |
sleep@plt | 0x400540 |
_start | 0x400550 |
main | 0x400582 |
__libc_csu_init | 0x400610 |
__libc_csu_fini | 0x400680 |
_fini | 0x400684 |
5.7 Hello的动态链接分析
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表 GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
5.8 本章小结
本章对简述了链接的概念和作用,给出了Linux系统下查看elf文件的指令,并通过分析可执行目标文件的elf格式文件,深入了解了可执行目标文件;对可执行目标文件进行反汇编,分析了重定位过程、执行流程、动态链接流程等。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程是计算机中正在执行的程序的实例,它包括了程序代码、数据和执行状态等信息。每个进程都有自己的地址空间、堆栈、文件句柄、环境变量等。
进程的作用包括:
(1) 实现并发执行:在多任务操作系统中,可以同时运行多个进程,从而实现并发执行。
(2)保护系统资源:每个进程都有自己的地址空间、堆栈等,可以保护系统资源不被其他进程非法访问。
(3)实现进程间通信:不同的进程之间可以通过进程间通信机制来交换信息,实现数据共享和协作。
(4)提高系统的可靠性:通过将任务分解成多个进程,可以提高系统的可靠性和容错性。
(5)方便管理和调度:操作系统可以通过进程控制块(PCB)来管理和调度进程,方便对系统资源进行管理和调度。
6.2 简述壳Shell-bash的作用与处理流程
Shell是操作系统的用户界面,它为用户提供了一种与操作系统交互的方式。Bash是Linux和macOS等操作系统中常见的Shell,它支持脚本编程,可以用来编写自动化任务和管理系统。
Bash的作用包括:
1. 提供命令行接口:用户可以通过Bash向操作系统发送命令,执行各种操作。
2. 支持脚本编程:Bash支持脚本编程,可以编写自动化任务和管理系统。
3. 管理环境变量:Bash可以管理系统的环境变量,方便用户进行系统配置和管理。
4. 支持管道和重定向:Bash支持管道和重定向等高级命令,可以方便地操作文件和进程。
Bash的处理流程包括:
1. 读取命令行输入:Bash从命令行读取用户输入的命令和参数。
2. 解析命令行参数:Bash解析用户输入的命令和参数,将它们转换成可执行的命令。
3. 执行命令:Bash执行用户输入的命令,调用相应的系统程序或脚本来完成操作。
4. 输出结果:Bash将命令执行结果输出到命令行界面或者重定向到文件中。
在Bash中,用户可以使用各种命令和操作符来完成各种操作,如文件操作、进程管理、系统配置等。同时,Bash还支持脚本编程,可以编写复杂的自动化任务和管理系统。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的、处于运行状态的子进程
int fork(void) 子进程返回0,父进程返回子进程的PID
新创建的子进程几乎但不完全与父进程相同
● 子进程得到与父进程虚拟地址空间相同的(但是独立的) 一份副本
● 子进程获得与父进程任何打开文件描述符相同的副本
● 子进程有不同于父进程的PID 的 fork函数:被调用一次,却返回两次!
6.4 Hello的execve过程
int execve(char *filename, char *argv[], char *envp[])
在当前进程中载入并运行程序
filename:可执行文件 ● 目标文件或脚本(用#!指明解释器,如 #!/bin/bash)
argv :参数列表,惯例:argv[0]==filename
envp:环境变量列表
● "name=value" strings (e.g., USER=droh)
● getenv, putenv, printenv
execve函数执行流程如下:
(1)execve函数首先需要获得要运行的程序的完整路径名以及命令行参数。这些信息通常会作为execve函数的参数传递给它。
(2)接下来,操作系统会检查要运行的程序是否存在,并且当前进程是否具有执行该程序的权限。如果检查通过,则会将要运行的程序加载到当前进程的内存空间中,覆盖掉当前进程的代码段和数据段。
(3)在加载程序的过程中,操作系统会创建一个新的进程映像,包括程序的代码段、数据段和堆栈段等部分,并且将其映射到当前进程的内存空间中。此时,当前进程的PCB会被更新,以反映新的进程映像的属性和控制信息。
(4)加载完成后,操作系统会将程序计数器设置为程序的入口地址,开始执行新的程序。此时,控制权已经被转移到了新的程序中。
(5)当新的程序执行完成后,控制权会返回到execve函数的调用位置。如果execve函数执行成功,则当前进程的代码段和数据段已经被新的程序覆盖,而堆栈段等其他部分则保持不变。
需要注意的是,execve函数加载新程序的过程中会覆盖当前进程的代码段和数据段,因此,在调用execve函数之前,通常需要在当前进程中保存好需要保留的数据和状态。
6.5 Hello的进程执行
hello的进程调度的过程可以分为以下几个步骤:
(1) 进程上下文信息保存:当操作系统需要切换当前正在执行的进程时,需要先保存当前进程的上下文信息,包括程序计数器、寄存器值、堆栈指针等。这些数据会被保存到当前进程的PCB中。
(2) 进程时间片切换:操作系统会将当前进程的时间片减少,并且检查当前进程是否已经用完了时间片。如果当前进程还有时间片剩余,它将继续执行;否则,操作系统会将其挂起,进行进程切换。
(3)进程调度算法选择:操作系统会根据当前的进程调度算法选择下一个需要执行的进程。常用的进程调度算法包括先来先服务(FCFS)、短作业优先(SJF)、时间片轮转(RR)等。
(4)进程上下文信息恢复:当操作系统选择了下一个需要执行的进程后,它会从该进程的PCB中恢复上下文信息(即第一步中保存的信息),并且设置新的程序计数器等,准备让该进程继续执行。
(5) 用户态与核心态转换:在进程切换的过程中,可能会涉及用户态和核心态的转换。当进程需要进行一些特权操作或者访问受保护的资源时,需要从用户态转换到核心态。此时,操作系统会将当前进程的特权级别提升,并且切换到内核模式下执行。当操作完成后,操作系统会将特权级别降低,并且返回用户态。
需要注意的是,进程调度的过程可能会涉及到多个进程,因此需要保证进程切换的正确性和稳定性。操作系统需要考虑进程的优先级、进程的等待队列、进程的时间片分配等因素,才能实现一个有效的进程调度算法。
图表 27 进程调度示意图
6.6 hello的异常与信号处理
6.6.1 异常
计算机系统主要有四类异常,分别是:中断、陷阱、故障和终止,逐类介绍如下:
1. 中断(Interrupt):中断是指计算机系统在运行过程中,由硬件或软件发出的一种请求,要求CPU暂时停止正在执行的程序,转而去执行另一段程序,以响应某种事件或处理某种请求。常见的中断有外部中断、时钟中断、输入输出中断等。
2. 陷阱(Trap):陷阱是指由程序故意触发的一种异常,通常用于实现系统调用或调试程序等操作。当程序执行到陷阱指令时,CPU会立即跳转到相应的陷阱处理程序中执行。
3. 故障(Fault):故障是指由于软件或硬件错误引起的一种异常。故障通常是由于程序错误、内存错误或硬件故障等原因引起的,例如缺页故障、除零故障等。
4. 终止(Abort):终止是指由于严重的故障或错误引起的一种异常,通常会导致系统崩溃或程序无法正常运行。终止通常是由于硬件故障、内存错误或系统安全漏洞等原因引起的,例如蓝屏、死机等。
6.6.2 信号
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。比如,下图展示了Linux系统上支持的30种不同类型的信号。每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
图表 28 Linux中的信号
6.6.3 运行hello时的信号处理
运行hello时,通过命令行提供参数:学号 姓名 间隔时间,其中间隔时间可以控制在终端输出的速度。运行结果如下:
图表 29 运行hello
在hello运行时输入回车和随机字符串对于程序的运行并没有影响,终端中会出现所有相关的输入,并不会影响到现有程序运行。
当向hello程序输入Ctrl-C后,会导致中断异常产生SIGINT信号,向子进程发出SIGKILL信号终止并回收,进程终止。
运行结果如下:
图表 30 随意输入,然后Ctrl-C
当向hello程序输入Ctrl-Z后,会导致中断异常产生SIGSTP信号,进程被挂起,与输入Ctrl-C结果不同。
图表 31 Ctrl-Z
此时分别输入ps, jobs, pstree, fg, kill指令进行查看相关信息。
输入ps可以查看进程的pid,cmd等相关信息,可以看出hello进程并未停止,而是被挂起。
图表 32 PS
输入jobs, 验证了hello进程确实被挂起,处于停止的状态。
输入pstree,可以通过进程树来查看所有进程的情况。
图表 33 pstree
fg指令使第一个后台作业变成前台作业,这里hello是第一个后台作业,所以fg会使得hello回到前台并完成运行。
图表 34 fg
结合ps指令输出的进程列表以及提示信息可知,kill指令成功杀死进程hello。
图表 35 kill
6.7本章小结
本章介绍了计算机系统进程管理的相关知识。首先简要介绍进程的概念和作用,然后简述了shell-bash的作用与处理流程。以hello的一生为例,依次介绍了进程创建、加载、执行、异常与信号处理,并在Linux系统上实际运行hello,展示了各种信号处理的实际情况的截屏。
第7章 hello的存储管理
7.1 hello的存储器地址空间
要保证多个应用程序同时处于内存中并且不互相影响,则需要解决两个问题:保护和重定位,现代计算机系统提供了较好的办法是创造一个新的内存抽象:地址空间。就像进程的概念创造了一类抽象的CPU以运行程序一样,地址空间为程序创造了一种抽象的内存。地址空间是一个进程可用于寻址内存的一套地址集合。每个进程都有一个自己的地址空间,并且这个地址空间独立于其他进程的地址空间。
(1)逻辑地址是由CPU生成的地址,它是对实际存储单元的抽象表示,通常是指程序中使用的地址。逻辑地址是程序员看到的地址,而不是实际的物理地址。
(2)线性地址是CPU生成的地址,它是逻辑地址经过分段机制和分页机制处理后得到的地址,分段机制和分页机制是计算机内存管理的两种重要技术。
(3)虚拟地址是在虚拟存储技术中使用的地址,它是程序中使用的地址,但不是实际存在于内存中的地址。在虚拟存储技术中,操作系统会将虚拟地址映射到实际的物理地址上,以实现更高效的内存管理。
(4)物理地址是在计算机内存中实际存在的地址,它是CPU通过地址总线访问内存时使用的地址。在计算机内存管理中,操作系统会将虚拟地址映射到物理地址上,以实现对内存的有效控制和管理。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel CPU采用了段式管理(Segmentation)的内存管理机制,它将逻辑地址转换为线性地址的过程就是段式变换,其具体步骤如下:
(1)将逻辑地址中的段选择器(Selector)作为索引,从全局描述符表(Global Descriptor Table,GDT)或局部描述符表(Local Descriptor Table,LDT)中读取段描述符(Segment Descriptor)。
(2) 根据段描述符中的基地址(Base Address)和偏移量(Offset)计算出线性地址。
(3)在进行地址变换时,还需要考虑到段描述符中的许多属性,如段限长(Limit)、特权级(Privilege Level)、读写权限(Read/Write)、执行权限(Execute/Non-Execute)等。
需要注意的是,Intel CPU在进行地址变换时,还需要考虑到分页机制(Paging)对地址的影响,因此,段式管理和分页机制是结合使用的。在实际应用中,操作系统会维护全局描述符表和局部描述符表,根据进程的需要进行切换,以实现对内存的控制和管理。
7.3 Hello的线性地址到物理地址的变换-页式管理
在计算机内存管理中,线性地址到物理地址的变换采用了页式管理(Paging)的机制。其基本思想是将物理内存按固定大小的块(称为页)划分,并将线性地址也划分为大小相等的块,称为页表项(Page Table Entry,PTE)。通过页表项中的映射关系,将线性地址映射到物理地址上,具体过程如下:
(1)将线性地址划分为页目录项(Page Directory Entry,PDE)和页表项(PTE)两部分,根据页目录项中的页表基地址,找到对应的页表。
(2) 在页表中查找页表项,根据页表项中的物理页框号和线性地址的页内偏移量计算出物理地址。
(3) 物理地址的计算通常还需要考虑到页表项中的一些属性,如读写权限、执行权限、存在位等。
需要注意的是,页式管理中,线性地址和物理地址都是按页划分的,页的大小通常为2的幂次方,如x86架构中的页大小为4KB。在实际应用中,操作系统会维护多级页表,以便更好地管理内存。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB(Translation Lookaside Buffer)是一种高速缓存,用于存储最近使用过的线性地址到物理地址的映射关系。在TLB中,每个条目包含一个线性地址和一个物理地址的映射,当CPU访问内存时,会首先在TLB中查找映射关系,如果找到了对应的条目,则直接使用TLB中的物理地址,从而加速内存访问。
在四级页表支持下的VA到PA的变换中,其具体步骤如下:
(1) 将虚拟地址(Virtual Address,VA)划分为四个部分:页目录索引、页表索引、页内偏移、页偏移。其中,页目录索引和页表索引用于查找页表,页内偏移和页偏移用于计算物理地址。
(2) 根据页目录索引,在一级页表中查找到二级页表的基地址,根据页表索引,在二级页表中查找到三级页表的基地址,依次类推,直到查找到四级页表的页表项。
(3) 在四级页表的页表项中,找到物理页帧号,并将其与页内偏移拼接起来,得到物理地址。
(4)在进行地址变换时,如果需要访问TLB中没有缓存的映射关系,需要进行页表的访问和加载,这会增加访问延迟和CPU的负载。
需要注意的是,四级页表是一种典型的分页管理机制,它支持大内存空间的映射和内存的动态分配,但也会增加地址变换的复杂度和访问延迟。因此,在实际应用中,需要权衡内存管理的需求和系统性能的要求,选择合适的内存管理机制。
图表 36 K级页表
7.5 三级Cache支持下的物理内存访问
三级Cache是现代计算机处理器中的一种高速缓存,它通常由三个层次的缓存组成:L1、L2和L3。其中,L1和L2缓存位于CPU内部,速度非常快,但容量较小;L3缓存则通常位于CPU芯片的外部,速度较慢,但容量较大。三级Cache的基本工作原理是将数据按照访问频率从内存中缓存到Cache中,以提高内存访问速度。
在三级Cache支持下的物理内存访问中,其具体过程如下:
(1) 当CPU需要访问特定的物理地址时,首先会检查L1缓存中是否有对应的数据。如果有,则直接从L1缓存中读取数据,并完成访问。
(2) 如果L1缓存中没有需要的数据,则会查找L2缓存。如果L2中有需要的数据,则将其读取到L1缓存,并从L1缓存中返回数据。
(3) 如果L2缓存中也没有需要的数据,则会查找L3缓存。如果L3中有需要的数据,则将其读取到L2缓存,并将其传递到L1缓存,最终从L1缓存中返回数据。
(4) 如果L3缓存中也没有需要的数据,则会从内存中读取数据,并将其缓存到L3缓存中,以便下一次访问时可以更快地读取数据。
需要注意的是,在三级Cache支持下的物理内存访问中,访问速度与缓存层级和缓存命中率有关。当数据可以从更高层级的缓存中读取时,访问速度更快;当数据需要从内存中读取时,访问速度较慢。因此,优化缓存命中率和缓存替换算法,可以有效提高系统访问速度。
图表 37 Core i7地址翻译全流程示意图
7.6 hello进程fork时的内存映射
在进程fork时,新的子进程需要复制父进程的内存映射关系。在Linux中,fork的内存映射过程可以分为以下步骤:
(1)内核为子进程创建一个新的地址空间,该地址空间与父进程的地址空间完全相同,但是每个虚拟页都没有实际的物理页面映射。
(2)内核复制父进程的页表,其中包括页表项的虚拟地址、标志和物理地址等信息。这个过程是通过复制父进程的二级页表和三级页表来实现的。在复制页表时,内核会将页表项中的标志设置为只读,这样可以防止子进程修改父进程的内存数据。
(3)内核将父进程中的所有用户空间的页表项中的物理页框复制到子进程的地址空间中,并更新子进程页表项中的物理地址。这个过程是通过COW(Copy-On-Write)机制来实现的。COW机制是指在父进程和子进程间共享相同的物理页面,当其中一个进程试图修改页面时,内核会将页面复制到该进程的地址空间中,从而保证两个进程的内存数据不互相影响。
(4)内核为子进程设置独立的页表,并恢复父进程中的页表标志,以便子进程可以正常访问和修改内存数据。
需要注意的是,由于fork操作会涉及到大量的内存复制和页表修改,因此其开销较大,尤其是在父进程的地址空间很大时。因此,在实际应用中,需要权衡fork操作的开销和进程间数据共享的需求,选择合适的设计方案。
7.7 hello进程execve时的内存映射
(1) ELF 文件读入内存:进程执行 execve 系统调用时,内核会将新程序的 ELF 格式文件读入内存中,这样内核就可以访问该文件的数据。
(2) 分配虚拟地址空间:内核会为新程序分配虚拟地址空间,这些虚拟地址空间用于存储新程序的代码和数据。分配虚拟地址空间的大小由 ELF 文件中的程序头表信息决定。
(3)映射代码段:代码段是包含程序的机器指令的部分,它通常是只读的。内核会将代码段映射到进程的虚拟地址空间中,并将其标记为只读。
(4)映射数据段:数据段包含程序中定义的全局变量和静态变量,通常是可读写的。内核会将数据段映射到进程的虚拟地址空间中,并将其标记为可读写。
(5)映射堆栈段:堆栈段用于存储函数调用的上下文信息和局部变量,通常是向下增长的。内核会将堆栈段映射到进程的虚拟地址空间中,并将其标记为可读写。
(6) 触发缺页异常:如果需要访问的地址空间不在进程的物理内存中,就会触发缺页异常。当内核捕获到缺页异常时,它会从文件中读取相应的数据,并将其填充到进程的地址空间中,以便进程可以继续执行。
(7)处理动态链接库:如果新程序需要使用动态链接库,内核会将动态链接库中的代码和数据映射到进程的地址空间中,以便新程序可以调用这些函数。
总之,进程执行 execve 系统调用时,内核会将新程序的代码和数据映射到进程的虚拟地址空间中,并且会处理动态链接库。这个过程中,内核会分配虚拟地址空间、映射代码段、数据段和堆栈段,并在需要时触发缺页异常。
7.8 缺页故障与缺页中断处理
缺页故障(Page Fault)是指当一个进程访问一个尚未分配物理内存或尚未在物理内存中的虚拟地址时,就会触发缺页故障。缺页故障是一种常见的异常情况,处理缺页故障的过程称为缺页中断处理。
缺页中断处理的主要过程如下:
(1)当进程访问一个尚未分配或尚未在物理内存中的虚拟地址时,CPU 会触发一个缺页异常,将控制权交给内核。
(2)内核会检查该虚拟地址所在的页是否已经在内存中,如果在内存中,则直接返回到进程继续执行。否则,内核会将该页从磁盘读入内存中。
(3)内核会选择一个物理内存页框(Page Frame),并将该虚拟页映射到该物理页框上。
(4)如果发现已经没有空闲的物理页框可用,那么内核会选择一个页来替换。这个过程称为页面置换(Page Replacement)。具体的置换算法有很多种,例如最近最少使用(LRU)、先进先出(FIFO)等。
(5)内核会更新页表,将虚拟页和物理页框之间的映射关系写入到页表中。
(6)最后,内核会将控制权返回到进程,让它继续执行。
总之,缺页故障是一种常见的异常情况,处理缺页故障的过程称为缺页中断处理。当进程访问一个尚未分配或尚未在物理内存中的虚拟地址时,CPU 会触发一个缺页异常,将控制权交给内核。内核会检查该虚拟地址所在的页是否已经在内存中,如果不在,则会将该页从磁盘读入内存中,并将其映射到一个物理页框上。最后,内核会将控制权返回到进程,让它继续执行。
7.9本章小结
本章介绍了程序的储存器层次结构,深入地了解了现代计算机系统的内存管理。先从程序所使用的不同地址开始,分别介绍了逻辑地址、虚拟地址(线性地址)以及物理地址,并介绍了计算机将地址从逻辑地址变化到虚拟地址再从虚拟地址变化到物理地址的流程。介绍了虚拟地址和物理地址之间的映射,以及进程fork和execve时内存与虚拟地址的映射。介绍了程序是怎么利用三级Cache来获取物理地址中所存放的数据的。最后介绍了计算机系统基于虚拟地址实现内存管理的实际工作中极为重要的概念——缺页故障及缺页中断处理
结论
hello程序在计算机系统中经历的过程可以分为以下几个阶段:
1. 编写程序:程序员使用编程语言编写程序,程序员需要了解编程语言的语法和规则,以及程序的逻辑和算法,将高级语言编写的程序存储到hello.c中。
2.预处理程序:预处理器将hello.c文件经初步处理变成hello.i
2. 编译程序:编译器将hello.i文件处理成为了汇编代码并保存在了hello.s中,然后汇编器将hello.s文件处理成可重定位目标程序,也就是hello.o,这个过程称为编译。编译器会检查程序的语法和错误,并将源代码翻译成能被计算机硬件执行的机器指令。编译器还会对代码进行优化,以提高程序的性能。
3. 链接程序:链接器将编译后的代码hello.o和库文件链接在一起,生成可执行文件。库文件是一些预编译好的代码,可以被程序调用。
4. 加载程序:shell收到运行./hello的指令之后,通过 fork创建子进程,操作系统会将可执行文件加载到内存中,并为程序分配内存空间。操作系统还会为程序分配一个唯一的进程标识符(PID),用于标识该程序的进程。
5. 执行程序:当程序开始执行时,CPU 会按照程序的指令逐条执行,控制 hello的逻辑流进行运行,其间调用printf、getchar等函数调用IO设备,进行屏幕的显示和键盘读入。
6. 异常处理:对于运行程序时键盘输入的ctrl-c、ctrl-z等指令系统中断并调用相应的信号处理程序进行处理
7. 退出程序:当程序执行完毕时,它会返回一个退出码(Exit Code),这个退出码表示程序的执行结果。操作系统会释放程序占用的内存空间和其他系统资源,并关闭程序的进程。
现代计算机操作系统的设计和实现是一个非常复杂和庞大的工程,需要涉及到多个领域,包括计算机体系结构、操作系统理论、编程语言和数据结构等。在这个过程中,我深刻认识到以下几点感悟:
首先,现代计算机操作系统的设计和实现要求高度的抽象能力和系统思考能力。在操作系统中,需要抽象出各种资源和服务,例如进程、线程、内存、文件系统等,以便于应用程序使用。同时,需要考虑到各种场景和需求,例如多任务处理、资源管理、安全性和可靠性等。这需要我们具备系统思考的能力,从全局的角度出发来考虑问题。
其次,现代计算机操作系统的设计和实现需要具备很高的代码质量和可维护性。操作系统是一个长期演化的系统,需要不断地进行升级和维护。因此,代码的可读性和可维护性非常重要。要做到这一点,需要遵循良好的编程规范和设计原则,例如模块化、接口设计、异常处理等。
最后,现代计算机操作系统的设计和实现需要具备很高的协作和沟通能力。操作系统是一个大型的软件系统,需要涉及到多个开发人员和团队。因此,需要进行良好的协作和沟通,以确保各部分代码的一致性和稳定性。同时,需要与用户和应用程序开发者进行良好的沟通,以了解他们的需求和反馈,以便于不断改进和优化操作系统。
总之,现代计算机操作系统的设计和实现需要具备抽象能力、系统思考能力、代码质量和可维护性、协作和沟通能力等多个方面的素质和能力。
附件
文件名称 | 作用 |
hello.c | C语言源代码 |
hello.i | 源代码在预处理后产生的文件 |
hello.s | 汇编语言文件 |
hello.o | 可重定位目标文件 |
hello | 二进制可执行文件 |
hello.txt | 可重定位文件hello.o的反汇编语言文件 |
参考文献
[1] 预处理、编译、汇编和链接_预编译 编译 汇编 链接_ClassRoom706的博客-CSDN博客
[2] Linux系统——fork()函数详解(看这一篇就够了!!!)_fork函数_代码拌饭饭更香的博客-CSDN博客
[3] 深入理解计算机系统 - 虚拟内存 - 知乎 (zhihu.com).
[4] 深入理解计算机系统-[美]Randal E.Bryant