计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能
学 号 2021113153
班 级 2103602
学 生 沈正冉
指 导 教 师 郑贵滨
计算机科学与技术学院
2023年5月
本文详细介绍了hello.c程序的一生——从源代码到经过预处理、编译、汇编、链接最终生成可执行目标文件hello,再通过在shell 中键入启动命令后,shell 为其fork并调用execve函数,产生子进程,内核为新进程创建数据结构, hello便从可执行程序(Program)变成为进程(Process)。
关键词:进程;计算机系统;编译;汇编;异常;链接;虚拟内存;I/O
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
(0.5分)
1.1 Hello简介
Hello的P2P就是Hello从一个可执行程序(Program)变为进程(Process)的过程。
首先用C语言编写Hello代码,将会得到Hello.c文件,之后会经历四个阶段:预处理阶段、编译阶段、汇编阶段、链接阶段。
图1.1 编译系统
预处理阶段预处理器根据hello.c文件生成hello.i文件,编译阶段编译器根据hello.i文件生成hello.s文件,汇编阶段汇编器根据hello.s文件生成hello.o文件,链接阶段连接器根据hello.o文件生成可执行目标文件hello。
经过这四个阶段,我们就得到了一个可执行程序hello,之后可以在shell中键入./hello指令,这样shell就会通过fork产生子进程,hello便从一个可执行程序(Program)变为进程(Process)了,这便是Hello的P2P过程。
Hello的020进程(om Zero-0 to Zero-0)便是Hello从一无所有到运行再到一无所有的过程。
初始时内存中并不存在Hello的相关内容,之后shell调用了execve函数,在新的子进程中加载并且运行hello。CPU需要为hello分分配所需的内存、时间片等。在hello运行的时候,CPU产生虚拟地址,该虚拟地址由MMU转化为物理地址,这中间还使用了TLB、4级页表、3级Cache、Pagefile等计数来加速对数据的访问。进程之后会结合IO设备,将信息“Hello World!”输出在显示器中。如果程序遇到了Ctrl-C键盘组合发送的SIGINT信号或者执行了return语句,hello进程将会将SIGCHLD信号发送给shell,shell便将hello回收,内存中关于Hello的内容便消失不见,又成了Zero。这便是Hello的020(om Zero-0 to Zero-0)的整个过程
1.2 环境与工具
硬件环境:
CPU:AMD Ryzen 7 5800H with Radeon Graphics
主板:LENOVO 16 12 16 SDKOL 77769 WIN
硬盘:
- SAMSUNG MZVLB512HAJQ-00000 512.1 GB
- SAMSUNG MZVLB512HBJQ-000L2 512.1 GB 1.2.2
软件环境:
Windows11 64 位;VMware® Workstation 16 Pro;Ubuntu20.04.4
调试工具:
Visual Studio 2022 64 位以上; gcc + gdb
1.3 中间结果
2、hello.i:hello.c经过预处理之后得到的文件
3、hello.s:hello.i经过编译之后得到的文件,其中是汇编指令
4、hello.o:经过汇编之后得到的可重定位目标文件
5、hello:hello.o经过链接之后得到的可执行目标文件
6、hello.elf:readelf读取hello.o得到的文件
1.4 本章小结
本章简单介绍了Hello的一生:从源代码文件到可执行程序,再到执行阶段,直到最后被回收的过程;此外,还对本文用到的系统环境和开发工具以及各种中间文件做了简单介绍。
第2章 预处理
(0.5分)
2.1 预处理的概念与作用
概念:
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如Hello.c中用到的#include<stdlib.h>命令告诉预处理器读取系统头文件stdlib.h的内容,并把它直接插入到程序文本中。预处理器还会用实际值替换#define定义的内容,并且删除注释和多余的空白符,最后就得到了一个以.i作为扩展名的文件。
作用:
1 头文件相关。预处理程序中的#include,将头文件的内容插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件。
2 宏定义相关。将宏名替换为文本(这个文本可以是字符串、可以是代码等)。
3 条件编译相关。根据#if以及#endif和#ifdef以及#ifndef来判断执行编译的条件。
2.2在Ubuntu下预处理的命令
命令:cpp hello.c > hello.i
截图:
图2.1 执行命令cpp hello.c > hello.i截图
2.3 Hello的预处理结果解析
打开hello.i发现共3060行,而预处理前hello.c只有23行(包含//的注释内容)。
图2.2hello.c 图2.3hello.i
hello.c中包含了三个头文件stdio.h、unistd.h和stdlib.h,预处理器将这三个头文件插入到文本中,如下图所示,main函数出现的位置已经到了3047行,并且将注释删除,其余和hello.c中的内容没有什么区别,hello.i仍是可阅读的文本文件。
图2.4 hello.i文件部分截图
可在usr/include路径上找到包含的头部文件,下图以stdlib.h文件为例:
图2.5 hello.i文件部分截图
同时预处理器将宏定义替换为真实值并且删除了注释。
2.4 本章小结
本章主要介绍了预处理的概念和功能,以及Ubuntu下的预处理指令,同时也以hello.c的预处理工作为例进行实践并分析,并且通过对比hello.c和hello.i分析了预处理过程中预处理器的工作:头文件替换、宏定义替换、删除注释、条件编译等。
第3章 编译
(2分)
3.1 编译的概念与作用
概念:编译器(ccl)基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,处理hello.i中的文本,并且以汇编指令的形式产生输出得到hello.s文件,也就是将高级语言变成汇编指令。
作用:汇编指令是一种低级语言指令,它为不同高级语言的不同编译器提供了通用的输出语言,生成.s汇编程序文件。
3.2 在Ubuntu下编译的命令
命令:gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s
截图:
图3.1 执行命令gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s截图
3.3 Hello的编译结果解析
打开hello.s进行分析发现hello.i被压缩成80行的hello.s:
图3.2 hello.s部分截图
3.3.1 数据:
①字符串:在.string的位置可以看到字符串,.LC1中的string就是for循环中printf语句的内容"Hello %s %s\n",但是.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”,这实际上是" 用法: Hello 学号 姓名 秒数!\n"中的中文符号的UTF-8格式,每个汉字符号在改变码中占据三个字节,共有9个汉字,所以用“\“分割开的数字一共是27个。
图3.3 string截图
②整数:int i, int argc都是函数中的局部变量,此外还有立即数0,4,8,32等。argc是函数中的参数,在调用函数时已经确定,i是在函数中定义的,被存储在寄存器或者栈中,在该次实验中i被存放在栈中,此时i并未被初始化,而是在下面循环语句中被初始化,下图31行既是在对i进行初始化(因为未初始化的变量有时并不保存,在初始化或赋值时才对其进行操作):
图3.4 i初始化
argc是函数的第一个参数,所以存在%rdi寄存器当中,但是由于在目前系统下int类型的变量为四字节,所以实际使用数据是用%edi即可,在下图中,可以看到第22行将%edi的值存到了栈中。
图3.5 argc参数的传递
此外还有0,1,4,32等立即数,汇编代码中立即数以$开头,见下图:
图3.6 立即数使用示例
③数组:
函数有参数char *argv[],在main中是第二个参数,用寄存器%rsi保存,指针数组中每个元素为指针,所以代码中对该数组进行操作时都用8字节r开头的寄存器,23行可见数组被保存到栈中:
图3.7 数组参数
L4部分对数组进行一些操作:见34,37行,因为每个元素八字节,所以35,38,45行操作时用的数值都是8的倍数,这几个操作是用数组首地址去找argv[]中的某个元素,如45行找到argv+24,即argv[3]的起始地址。
图3.8 数组元素寻址
3.3.2赋值操作:
.c文件中赋值操作有:i=0,i++。
①i=0:上述也分析过,即在31行对i赋初值(上述分析过将i存在栈中),使用movl指令(因为int4字节)。
图3.9 movl指令
②i++:循环体中每次循环后判断条件成立后要执行i++操作,见51行,即进行i=i+1的操作,也是用的addl指令,l表示4字节。
图3.10 i++操作
3.3.3算术操作:
汇编语言中常见整数操作见下图:
图3.11 常见整数操作
该程序中所用的有:i++,寻址,
①i++:
图3.12 i++操作 对i执行+1操作
②leaq操作:
图3.13 leaq操作 为sleep函数传递参数。
③数组寻址:、
图3.14 数组元素 寻址找到argv数组首地址,然后通过元素大小*偏移量去找想要的某个数组元素。
3.3.4控制转移:
在本程序中体现为ifelse for操作:
①ifelse:if判断语句,汇编代码中使用CMP与jump指令完成,将argc的值与4相比,je表示相等则跳转。
图3.15 if语句示例
②for循环:
L4是循环体内部操作,L3是判断操作,cmpi的值与7,i<=7时接着进行循环体操作(即跳回到L4),否则调用getchar函数。
图3.16 for循环源代码
图3.17 for循环对应汇编代码
3.3.5函数操作:
源代码中的函数有main函数、printf函数(第一处被优化为puts函数)、sleep函数、getchar函数、atoi函数和exit函数。
①参数传递:以调用sleep函数传递参数为例,因为sleep函数有一个参数,所以用%rdi寄存器传递参数,见49行(注:参数传递顺序为%rdi, %rsi, %rdx, %rcx, %r8, %r9,若参数个数超过6个,多余的参数用栈传递):
图3.18 参数传递
②函数调用:这部分比较简单,直接使用call指令,参数为所要调用的函数的起始地址即可:
图3.19 sleep函数调用 (50行call sleep函数,@后是参数,48行atoi函数类似)
特殊:printf函数,hello.c程序的.o文件中将printf函数优化为puts函数所以27行调用puts函数。
图3.20 puts函数调用
③局部变量/赋值操作:以main函数中局部变量i为例:可见初始定义时并没有对i进行操作,只有在后面31行对i赋初值时才进行操作。其实有时有的赋初值操作汇编代码也不进行,而是在后续其他操作时才体现出来,比如i=0,后面有for(i=1;i<n;i++)这种操作时,编译器会自动将i=0这个赋初值操作优化掉,而在后续i=1这时才进行赋值操作,原因也很简单,i=0后面又被i=1覆盖,0并没有产生什么影响,所以不需要赋值0。
图3.21 局部变量与赋值操作
④参数返回:一般情况直接ret指令即可,若有返回值时使用%rax寄存器保存返回值并在后续继续使用。
main函数通过调用exit函数退出或者使用return语句返回。如果argc!=4,就退通过exit退出,返回值是1,否则通过return返回0。
printf函数的返回值是整形数据,大小为输出字符串的个数,不过这里并没有使用到。
atoi函数的返回值就是输入字符串转化成的数字。
sleep函数的返回值是剩余时间,不过这里也没有使用到。
exit函数执行之后直接结束进程,返回值对其无用,他也不产生返回值。
getchar:该函数以无符号 char 强制转换为 int 的形式返回读取的字符,如果到达文件末尾或发生读错误,则返回 EOF。
3.3.6关系操作:
该例子中关系操作存在于if语句和for语句中:
①if语句:13行判断argc与4的大小关系,在24行使用cmpl语句,若相等就跳到循环体开始执行,否则进入if语句中。
图3.22 if语句
②for语句:L2在初始化(i=0),L4是循环体内部操作,L3是循环结束条件判断,若i<=7则继续下一轮循环,否则跳出。
图3.23 for循环体汇编代码
3.4 本章小结
本章介绍了编译及其相关操作,并以具体实例进行分析。编译是将hello.i编译成汇编文本文件hello.s。hello.s中是程序的汇编代码,研究汇编代码可以帮助我们分析哪一条指令耗时较长,用以优化代码,这是一种不同于算法层面,而是靠近于底层的优化(指令级别)。
第4章 汇编
(2分)
4.1 汇编的概念与作用
概念:编译器(as)将hello.s翻译成机器语言指令,这些指令打包成一种可重新定位目标程序的格式。hello.o文件是二进制形式的文件,直接用记事本打开将是乱码。
作用:将汇编指令转化成机器语言指令;并将结果保存在目标文件hello.o中。
4.2 在Ubuntu下汇编的命令
命令:as hello.s -o hello.o
截图:
图4.1 执行命令as hello.s -o hello.o截图
4.3 可重定位目标elf格式
①获取可重定位目标文件
输入命令readelf -a hello.o >hello.elf将elf可重定位目标文件输出定向到文本文件hello.elf中,见下图。
图4.2 执行命令readelf -a hello.o >hello.elf截图
②ELF各节信息
典型ELF可重定位目标文件的格式如下:
图4.3典型ELF可重定位目标文件
各节内容(对照上表分析):
ELF头:以一个十六字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小,目标文件的类型(如可重定位的、可执行或者共享的)、机器类型(如x86-64),节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小都是由节头部表描述的,其中目标文件中的每个节都有一个固定大小的条目。ELF的信息如下所示
图4.4 hello.o的ELF头
.text:已编译程序的机器代码。
.rodata:只读数据,比如printf语句中的格式串和开关语句中的跳转表
.data:已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss中。
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态C变量。
.symtab:一个符号表,他存放在程序中定义和引用的函数和全局变量的信息。符号表的实际内容如下:
图4.5 symtab符号表内容
.rel.text:一个.text节中位置的列表,当连接器把这个目标文件和其他文件组合时,需要修改这些位置。该部分的实际内容如下图所示
图4.6rel.text内容
.rel.data:被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果他的初始值是一个全局变量地址或者外部定义函数的地址。都需要被修改。
.debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序的时候,才会得到这张表。
.line:原始C程序中的行号和.text节中机器指令之间的映射。同样需要-g 选项才会出现。
.strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头中的节名字。字符串表是以null结尾的字符串序列。
节头部表:节头表包括节名称,节的类型,节的属性(读写权限),节在ELF文件中所占的长度以及节的对齐方式和偏移量。我们可以使用终端指令readelf -S hello.o来查看节头表。节头部表的实际内容如下图
图4.7 节头部表
4.4 Hello.o的结果解析
使用objdump -d -r hello.o 得到hello.o的反汇编代码
图4.8hello.o的反汇编代码
机器语言由机器指令集构成,能够直接被机器执行。
机器语言与汇编语言的映射关系:汇编语言接近机器语言,可以看做是机器语言的另一种形式,计算机在运行时也需要将其变为机器语言的二进制才可运行。
与第三章hello.s对照分析:
大部分内容一至,不同点:
①函数调用:.s文件中call后面直接跟着的是函数名,而objdump中后面跟的是main加相对位移量(地址),具体见下图:
图4.9 .s文件与objdump关于函数调用部分的区别
②跳转函数:与函数调用区别相同,都是后面跟的部分变了,.s文件中是L2这种,而objdump中后面跟的是地址,具体见下图:
图4.10 .s文件与objdump关于跳转函数部分的区别
③字符串:.s文件中printf第一个参数填的是节的名称,即rodata段,而objdump中参数部分为0,因为节的位置要等到重定位后才知道,所以填0用来占位,并在.rela.text节中添加重定位条目,见下图:
图4.11 .s文件与objdump关于字符串部分的区别
④立即数表示方式不同:.s中用$的十进制表示,而objdump中用0x的十六进制表示,见下图:
图4.12 .s文件与objdump关于立即数部分的区别
4.5 本章小结
本章介绍了汇编及其过程。汇编器(as)将汇编语言翻译成机器语言指令,把这些指令打包可重定位目标程序,并将结果保存在hello.o中。hello.o文件是一个二进制文件,它包含的是函数main的指令编码。同时对hello.o文件进行反汇编,将反汇编代码与之前生成的hello.s文件进行了对比。使得我们对该内容有了更加深入地理解。
第5章 链接
(1分)
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合成为一个单一文件(可执行目标文件)的过程,这个文件可被加载(复制)到到内存并执行。
作用:链接使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
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
截图:
图5.1 执行链接命令示意图
5.3 可执行目标文件hello的格式
命令:readelf -a hello
ELF头:
图5.2 hello可执行目标文件的ELF头
各段基本信息:
图5.3 hello可执行目标文件的各段基本信息
图5.3续图
5.4 hello的虚拟地址空间
典型linux进程虚拟内存:
图5.4典型linux进程虚拟内存
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明,如下图:
图5.5 edb加载hello
从下图可以看出,从地址0x00400000处开始,程序被载入,且程序的第一行和elf查看hello中Magic的值是相同的,这说明程序是从0x400000的位置开始载入。
图5.6 hello中Magic的值
下图为elf中的程序头表,记录了运行时加载的内容,同时提供动态链接的信息。每一行都提供了各个段的虚拟空间和物理内存的大小,标志位,是否对齐,读写权限的信息。
PHDR:程序头表
INTERP:需要调用的解释器(如动态链接器)
LOAD:表示需要从二进制文件映射到虚拟空间的段,其中保存了常量数据和目标代码等内容。
DYNAMIC:动态链接器使用的信息。
NOTE:辅助信息
GUN_STACK:栈是否可执行的标志。
GUN_RELRO:指定重定位之后的只读的内存区域。
图5.7 elf中的程序头表
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程:
①节有所不同,多了一些节如init,plt节:
图5.8 hello与hello.o关于节的不同
②地址变化:
增加了一些外部链接的共享库函数如下图所示,并且使用的地址都是虚拟地址:
图5.9 hello与hello.o关于地址的不同
结合hello.o的重定位项目,分析hello中对其怎么重定位的:
链接的重定位过程说明,链接过程中合并了相同的节,例如,来自hello.s中所有输入模块的.data节被全部合并成一个节,这个节成为输出的hello的.data节。确定了新节中所有定义符号在虚拟地址空间中的地址,还对引用符号进行重定位(确定地址),修改.text节和.data节中对每个符号的引用(地址),使得它们指向正确的运行时地址,而这些需要用到在.rel_data和.rel_text节中保存的重定位信息。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程:
在调用main之前主要进行了初始化的工作调用了_init,在这个函数之后动态链接的重定位工作已经完成,后续是一系列库函数的调用,这些库函数并不占用实际的内存,之后调用_start(起始地址),后面开始执行main的内容,执行完main之后还会执行__libc_csu_init 、__libc_csu_fini 、_fini等函数,最终这个程序才结束。
请列出其调用与跳转的各个子程序名:
- ld-2.27.so!_dl_start:
- ld-2.27.so!_dl_init:
- hello!_start:
- libc-2.27.so!__libc_start_main:
- -libc-2.27.so!__cxa_atexit:
- -libc-2.27.so!__libc_csu_init:
- hello!_init:
- libc-2.27.so!_setjmp
- -libc-2.27.so!_sigsetjmp
- –libc-2.27.so!__sigjmp_save
- hello!main
- hello!puts@plt
- hello!exit@plt
- ld-2.27.so!_dl_runtime_resolve_xsave
- -ld-2.27.so!_dl_fixup
- –ld-2.27.so!_dl_lookup_symbol_x
- libc-2.27.so!exit
5.7 Hello的动态链接分析
从hello的elf表中可以得到,.got表的地址为0x0000000000403ff0,在datadump中跳到该地址。
图5.10 got表的地址
在dl_init之前的GOT的内容如下:
图5.11 dl_init之前的GOT
在dl_init之后GOT的内容如下:
图5.12 dl_init之后的GOT
内容变化:开始edb调试后,初始的地址0x0000000000403ff0全为0。对于动态共享链接库中PIC函数,编译器无法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器的处理,为避免运行时修改调用的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT + 全局变量偏移表GOT实现函数的动态链接,GOT中存放目标函数的地址,PLT使用该地址跳转到目标位置。
5.8 本章小结
这章讲述了链接的指令,详细地阐述了是怎么将hello.o 与其他文件链接,最后变成可执行程序hello,并且分析了hello的ELF格式。此外,本章节还探究了虚拟地址空间、重定位过程、执行流程、动态链接过程,用EDB实际操作分析了程序运行时的参数、属性。
第6章 hello进程管理
(1分)
6.1 进程的概念与作用
作用:提供了两个假象
- 一个独立的逻辑控制流,好像我们的程序独占地使用处理器
- 一个私有地地址空间们好像我们地程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:Linux系统中,Shell是一个交互型应用级程序,代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程)。其基本功能是解释并运行用户的指令。
流程:
- 终端进程读取用户由键盘输入的命令行
- 分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
- 检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令
- 如果不是内部命令,调用fork( )创建新进程/子进程
- 在子进程中,用步骤2获取的参数,调用execve( )执行指定程序
- 如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait…)等待作业终止后返回
- 如果用户要求后台运行(命令末尾有&号),则shell返回
6.3 Hello的fork进程创建过程
执行中的进程调用fork()函数,就创建了一个子进程。其函数原型为pid_t fork(void);对于返回值,若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1。
我们向终端输入命令,shell会首先判断他是不是一个内置命令。若不是内置命令,shell会先使用fork函数创建一个新进程,对我们的输入进行解析,解析结束之后会调用execve函数在这个进程当中运行hello程序,并且带上我们的参数。
6.4 Hello的execve过程
- execve尝试根据输入的地址运行一个可执行程序,如果失败就返回-1,如果成功就不返回,继续向下执行。
- 在execve成功加载了hello程序之后,execve 调用驻留在内存中的被称为启动加载器的操作系统代码来执行 hello 程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。 新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。
- 之后跳转到_start,_start 函数调用系统启动函数__libc_start_main 来初始化环境,调用用户层中hello 的 main 函数,并在需要的时候将控制返回给内核。
6.5 Hello的进程执行
上下文切换机制:操作系统内核使用一中称为上下文切换的较高层形式的异常控制流来实现多任务:内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
上下文切换的流程:1.保存当前进程的上下文。2.恢复某个先前被抢占的进程被保存的上下文。3.将控制传递给这个新恢复的进程。
图6.1 时间片示例
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
处理器通常是用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
图6.2 上下文切换示例
Hello进程调度的过程:
hello的一个上下文切换是调用sleep函数时,hello 显式地请求休眠,控制转移给另一个进程,此时计时器开始计时,当计时器到达argv[3]时,它会产生一个中断信号,中断当前正在进行的进程,进行上下文切换,恢复 hello 在休眠前的上下文信息,控制权回到 hello 继续执行。当循环结束后,hello 调用 getchar 函数,之前 hello 运行在用户模式下,在调用 getchar 时进入内核模式,内核中的陷阱处理程序请求来自键盘缓冲区的 DMA传输,并执行上下文切换,并把控制转移给其他进程。当完成键盘缓冲区到内存的数据传输后,引发一个中断信号,此时内核从其他进程切换回 hello 进程,然后 hello执行 return,进程终止。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
异常类型:
原因 | 异步/同步 | 返回行为 | |
中断 | 来自I/O信号的设备 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
①正常执行:
图6.3 hello正常执行
②运行的过程中发送SIGINT(Ctrl-C)信号:
图6.4 hello执行过程中键入Ctrl-C
③运行的过程中发送SIGTSTP(Ctrl-Z)信号:
图6.5 hello执行过程中键入Ctrl-Z
可以看到发送SIGTSTP信号之后,程序被停止挂起,输入ps指令可以看到后台中有hello程序,通过fg指令可以将该程序调到前台继续执行并且正常退出。
图6.6 hello执行过程中键入Ctrl-Z后再键入fg
总共还是打印了8条语句。
Ctrlz后输入jobs:
图6.7 hello执行过程中键入Ctrl-Z后再键入jobs
Ctrlz后输入pstree:
图6.8 hello执行过程中键入Ctrl-Z后再键入pstree
Ctrlz后输入kill:(kill-l显示可产生信号的种类共64种)
图6.9 hello执行过程中键入Ctrl-Z后再键入kill
④运行过程中发送其他指令/信号:
如果发送的shell的内部指令,则在hello执行结束之后会执行输入的内部指令:
图6.10 hello执行过程中键入其他内部指令
如果输入的不是内部指令,则hello执行结束之后shell会显示“command not found”
图6.11 hello执行过程中键入其他非内部指令
6.7本章小结
本章节研究了shell对进程的管理,进程看起来好像是独享处理器和内存空间,但是实际上是通过上下文切换机制在不同进程之间来回切换。用户可以在shell中输入指令来开始新的进程。shell会先分析输入的语句,并且根据指令判断是否要添加一个子进程,如果需要添加子进程,则会使用fork创建子进程,并在子进程中用execve函数加载目标程序。进程运行的时候可能会碰到外部异常信号,进程会根据信号做出相应反应。进程接收到信号之后可能会被挂起,可能会直接停止运行,也可能不做出相应反应,这个输入信号的种类以及进程对信号的处理方法有关。
第7章 hello的存储管理
( 2分)
7.1 hello的存储器地址空间
逻辑地址:逻辑地址(Logical Address)是指由程序hello产生的与段相关的偏移地址部分(hello.o)。
线性地址:线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生逻辑地址,或者说是(即hello程序)段中的偏移地址,它加上相应段的基地址就生成了一个线性地址。
物理地址: CPU通过地址来访问内存中的单元,地址有虚拟地址和物理地址之分,如果CPU没有MMU(Memory Management Unit,内存管理单元),或者有MMU但没有启用,CPU核在取指令或访问内存时发出的地址(直接是物理地址)将直接传到CPU芯片的外部地址引脚上,直接被内存芯片(以下称为物理内存,以便与虚拟内存区分)接收,这称为物理地址(Physical Address)
虚拟地址:如果CPU启用了MMU,CPU核发出的地址将被MMU截获,从CPU到MMU的地址称为虚拟地址(Virtual Address)。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理流程图如下:
图7.1段式管理示例图
(段选择符)
图7.2 段选择符具体内容
逻辑地址共48位,16位为段选择符,32位为段内偏移量
段选择符的具体内容:
- 索引:描述符表的索引(Index)
- TI:如果 TI 是 0。描述符表是全局描述符表(GDT),如果 TI 是 1。描述符表是局部描述表(LDT)
- RPL:段的级别。为 0,位于最高级别的内核态。为 11,位于最低级别的用户态。在 linux 中也仅有这两种级别。
保护模式下的段寻址:
段寄存器为全局描述符表项的寻址:1-2-3
段寄存器为局部描述符表项的寻址:1’- 2’- 3’-4’-5’
名词解释:局部段描述符表寄存器(LDTR)
全局描述符表寄存器(GDTR)
图7.3 段寻址过程示意图
总和上述文字和流程图,逻辑地址到线性地址的总流程为:首先根据段选择符的 TI 部分判断需要用到的段选择符表是全局描述符表还是局部描述符表,随后根据段选择符的高 13 位的索引(描述符表偏移)到对应的描述符表中找到对应的偏移量的段描述符,从中取出 32 位的段基址地址,将 32 位的段基址地址与 32 位的段内偏移量相加得到 32 位的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
Linux组织虚拟内存示意图如下:
图7.4 页式管理示意图
上图强调了记录一个进程中虚拟内存区域的内核数据结构。内核为系统中的每个进程维护一个单独的任务结构(源代码中的task_struct)。任务结构中的元素包含或者指向内核运行该进程所需要的所有信息(例如,PID、指向用户栈的指针、可执行目标文件的名字,以及程序计数器)。
任务结构中的一个条目指向mm_struct,它描述了虚拟内存的当前状态。我们感兴趣的两个字段是pgd和mmap,其中pgd指向第一级页表(页全局目录)的基址,而mmap指向一个vm_area_structs(区域结构)的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域。当内核运行这个进程时,就将pgd存放在CR3控制寄存器中。
一个具体区域的区域结构包含下面的字段:
vm_start:指向这个区域的起始处。
vm_end:指向这个区域的结束处。
vm_prot:描述这个区域内包含的所有页的读写许可权限。
vm_flags:描述这个区域内的页面是与其他进程共享的,还是这个进程私有的(还描述了其他一些信息)。
vm_next:指向链表中下一个区域结构。
系统将虚拟内存的每个段分为若干个小单位,并以此为单位将数据装入内存,这个最小的单位被称为页,一般每个页的大小为4KB。同时,物理内存中也会分为同样大小的若干个页。CPU中的MMU单元负责翻译地址,将虚拟地址根据页表中的记录翻译为物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
图7.5 intel corei7 内存管理系统示意图
CPU给出一个VA给到MMU,MMU通过这个虚拟地址中的虚拟页号去四级页表中找物理页号,具体过程:将36位虚拟页号分为4部分,每部分9位,每部分去对应的页表中寻找,前三级页表都用来寻找下一级页表的页表项,第四级页表找物理页号,找到后与虚拟地址的12位页内偏移量拼起来变成物理地址,若使用TLB(快表),则MMU直接去TLB中用36位虚拟页号进行比较,若匹配上了则直接找到物理页号,然后与虚拟地址的12位页内偏移量拼起来变成物理地址,没找到则按上述方法去四级页表里面找。
7.5 三级Cache支持下的物理内存访问
以intel corei7的三级cache为例:
图7.6 intel corei7 物理寻址过程示意图
首先,根据物理地址的 s 位组索引索引到 L1 cache中的某个组,然后在该组中查找是否有某一行的标记等于物理地址的标记并且该行的有效位为 1,若有,则说明命中,从这一行对应物理地址 b 位块偏移的位置取出n个字节,若不满足上面的条件,则说明不命中,需要继续访问下一级 cache,访问的原理与 L1 相同,若是三级 cache 都没有要访问的数据,则需要访问内存,从内存中取出数据并放入cache。
7.6 hello进程fork时的内存映射
1)虚拟内存和内存映射解释了fork函数如何为hello进程提供私有的虚拟地址空间。
2)fork为hello的进程创建虚拟内存
创建当前进程的的mm_struct,vm_area_struct和页表的原样副本;两个进程中的每个页面都标记为只读;两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW)
3)在hello进程中返回时,hello进程拥有与调用fork进程相同的虚拟内存。
4)随后的写操作通过写时复制机制创建新页面
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
①删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
②映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中栈和堆区域也是请求二进制零的,初始长度为零。
③映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库1ibc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
④设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
1)判断虚拟地址A是否合法,换句话说,A在某个区域结构定义的区域内吗?为了回答这个问题,缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。因为一个进程可以创建任意数量的新虚拟内存区域(使用在下一节中描述的mmap函数),所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Linux使用某些我们没有显示出来的字段,Linux在链表中构建了一棵树,并在这棵树上进行查找。
2)试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。
3)此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。
7.9本章小结
本章介绍了程序是如何组织储存器的。先从程序所使用的不同地址开始,分别介绍了逻辑地址、虚拟地址(线性地址)以及物理地址。并介绍了计算机是怎么一步步将地址从逻辑地址变化到虚拟地址再从虚拟地址变化到物理地址的。其中着重介绍了虚拟地址和物理地址之间的映射,以及进程是怎么映射到虚拟地址空间的。之后还介绍程序是怎么利用Cache来获取物理地址中所存放的数据的。最后简单介绍了虚拟地址中极为重要的概念——缺页异常。
结论
(0分,必要项,如缺失扣1分,根据内容酌情加分)
- hello.c:源代码。
- hello.i:编译系统对hello.c的第一步处理。
- hello.s:经过汇编器汇编之后得到的有汇编指令组成的文件
- hello.o:经过汇编阶段得到的可重定位的二进制文件
- hello:hello.o与可重定位目标文件和动态链接库链接成为可执行程序 hello。至此可执行hello程序正式诞生。
- 运行:bash进程解析输入的指令,之后调用fork函数生成子进程,在子进程中使用execve函数运行hello程序。
- 地址:hello运行的过程中会出现各种地址,但是为了读取内存中的数据,我们最后需要通过物理地址取定位数据。
- 上下文切换:hello调用sleep函数之后,模式修改为内核模式,内核进行上下文切换将控制权交给其他进程。当sleep函数结束之后内核会再一次执行上下文切换将控制权交换给hello。
- 动态申请内存:hello执行printf函数的时候,会调用malloc向动态内存分配器申请堆中的内存。
- 信号管理:hello运行的时候可能会收到外部的信号,之后hello进程会根据信号的类型和对特定信号的处理操作来做出相应的反应。
- 终止:子进程结束之后会向父进程发送信号,父进程接收到信号之后会回收子进程,释放子进程占用的资源。
感悟:hello从诞生到结束,经历了千辛万苦,在硬件、操作系统、软件的相互协作配合下,终于完美地完成了它的使命。在使用shell时我感受到其强大的功能,对指令的解释,分析与执行能力,我深刻体会到要设计一个好的计算机系统需要在大局观上要让设计出来的每一个部分实现功能上的紧密相连,完美的设计逻辑上的关联顺序。同时,计算机系统提供的一系列抽象使得实际应用与具体实现相互分离,可以很好地隐藏实现的复杂性,降低了程序员的负担,使得程序更加容易地编写、分析、运行。
附件
1、hello.c:源代码文件
2、hello.i:hello.c经过预处理之后得到的文件
3、hello.s:hello.i经过编译之后得到的文件,其中是汇编指令
4、hello.o:经过汇编之后得到的可重定位目标文件
5、hello:hello.o经过链接之后得到的可执行目标文件
6、hello.elf:readelf读取hello.o得到的文件
列出所有的中间产物的文件名,并予以说明起作用。
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Randal E. Bryant, David R. O’Hallaron . 深入理解计算机系统[M]. 北京:机械工业出版社,2016.7.
[2] 菜鸟教程:https://www.runoob.com
[3] jiangxt211. C预处理.
Available at C预处理_c 预处理_jiangxt211的博客-CSDN博客
[4] 段页式访存——逻辑地址到线性地址的转换
[5] printf 函数实现的深入剖析. https://www.cnblogs.com/pianist/p/3315801.html
[6] 网络用户. 阿里云. ELF格式文件符号表全解析及readelf命令使用方法. 2018:07-19. https://www.aliyun.com/zixun/wenji/1246586.html
(参考文献0分,缺失 -1分)