计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能
学 号 2021112002
班 级 2103602
学 生 唐家誉
指 导 教 师 郑贵滨
计算机科学与技术学院
2023年5月
本文通过关注一个最基础的程序hello在计算机系统中所经历的一生,回顾了一个程序从以高级语言编写诞生后,如何通过预处理、编译、汇编、链接,最终得以成为一个可执行程序,以及最终如何通过加载、运行、终止、回收完成自己的使命。一个简单的hello,有着其高级语言程序层面的简单性,但它的一生却并不平凡,通过与编译器、操作系统交互,它十分复杂地完成了它的一生,然而从另一个层面来说,由于每个其他程序,即使是再复杂的程序也有着类似地一生,程序的一生也有着简单的共性。通过hello程序来研究这样的一种共性,不仅可以探寻hello的生命历程,更可以以小见大,探索到整个计算机系统的奥秘。
关键词:计算机系统;P2P;O2O;预处理;编译;汇编;链接;进程;shell;
目 录
第1章 概述
1.1 Hello简介
1、P2P:
P2指的是Program to Progress。Hello程序要生成可执行文件首先要进行预处理:预处理器根据以#开头的命令,修改初原始的C程序,生成hello.i文件;然后进行编译,将文本文件hello.i翻译成文本文件hello.s;接着进行汇编,将hello.s翻译成机器语言指令并生成hello.o文件。最后进行链接,由链接器生成可执行文件。整个过程如下图所示:
图1.1.1 P2P过程
2、020:
020为from 0 to 0,表述了程序从无到有又从有到无的过程。可执行文件需要执行环境,它可以在linux下通过shell进行运行,与计算机其他可执行文件同步与运行,并通过异常处理机制对响应信号进行处理。shell执行hello后为其映射虚拟内存,随后载入物理内存开始执行hello的程序,将其输出到屏幕,随后结束进程。程序运行结束后,父进程回收子进程并清除相关数据。此时即为hello程序生命的终结。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;1GHz;16G RAM;256G GHD Disk
1.2.2 软件环境
Windows10 64位;VMware Workstation 16.0;Ubuntu20.04 LTS 64位
1.2.3 开发工具
Visual Studio Code 2019;gcc;gdb;objdump;readel;vim
1.3 中间结果
hello.c c语言源程序
hello.i 预处理生成的文本文件
hello.s hello.i编译后得到的汇编语言文件
hello.o hello.s生成的二进制文件
hello.txt hello.o反汇编后得到的文本文件
hello_1.txt hello反汇编得到的文本文件
hello.elf hello.o的elf文件
hinkhello.txt hello的elf文件
hello hello的可执行文件
1.4 本章小结
本章主要介绍了Hello程序的一生,从有到无再从无到有,并介绍了相关的应用工具。
第2章 预处理
2.1 预处理的概念与作用
1、概念:预处理一般指由预处理器对程序源代码进行处理的过程。预处理器(cpp)根据字符#开头的命令,修改原始的C程序。将#的部分的详细源码替换掉原来那行,同时删除文件中的注释。
2、作用:
1)#define宏定义: 将宏名替换为文本: 字符串或者代码。
2)#include文件包含: cpp将查找指定的被包含头文件, 并将其复制插入到#include命令出现的位置上。
3)#if #else #endif 等条件编译: 有些语句希望在条件满足时才编译, 预处理过程中根据条件决定需要编译的代码。
2.2在Ubuntu下预处理的命令
预处理的命令:gcc -E hello.c -o hello.i
图2.2.1 预处理命令
2.3 Hello的预处理结果解析
预处理结果解析:
根据生成的hello.i文件可以看出原始的c文件二十几行的内容经过预处理后变成了三千多行,前面大部分都是将#include中的内容复制插入后的内容,原始文件中的#include<stdio.h> 、#include<unistd.h>、 #include<stdlib.h>库函数依次被替换展开,占据了前面的大部分内容,而原始程序对应了处理后的3078~3091行并且未发生任何改变。
图2.3.1 hello.i中的库函数声名
图2.3.2 hello.i
2.4 本章小结
本章介绍了预处理的概念和作用,然后介绍了linux系统下的生成hello.i的预处理命令,最后通过查看hello.i文件中的实际内容来了解预处理的实际结果,通过分析结果,加深对于预处理的理解。
第3章 编译
3.1 编译的概念与作用
1、概念: 编译是指利用编译器将hello.i文件编译得到hello.s汇编语言文件的过程。
2、作用: 将高级语言翻译为机器能够理解的汇编语言。
3.2 在Ubuntu下编译的命令
编译的命令:gcc -S hello.i -o hello.s
图3.2.1编译的命令
3.3 Hello的编译结果解析
3.3.1总体说明
文件开头部分是一些基本说明,比如.file说明了文件名“hello.c”,.align说明了对齐方式为8,.text指明是代码段,.rodata指示为只读数据段等等,中间部分是主体代码,最后也是一些关于编译结果的说明。
3.3.2数据分析
根据原始c程序的顺序从头往后分析逐个数据
1、int i:在汇编语言程序中,将i这一局部变量保存在程序运行时栈中,可以看出i占了4个字节,并且为0(由于循环里i=0语句)
图3.3.2.1汇编文本中的变量i
2、argc:从文件中可以看出,只是命令行参数个数的argc由寄存器edi传入,然后被放在了栈中。
图3.3.2.2汇编文本中的argc
3、参数argv:可以看到,命令行参数的数组的首地址通过寄存器传入,然后将数组的首地址存放在了栈中(先通过subq $32,%rsp开辟了32字节的栈空间)。
图3.3.2.3 汇编文本中的argv
4、字符串:原始c程序中printf中的字符串常量被放在了文件开头部分的.text数据段(.rodata)中,汉字采用了UTF-8编码,一个汉字占3个字节。
图3.3.2.4汇编文本中的字符串
5、立即数:程序中用到的立即数。
图3.3.2.4 汇编文本中的立即数
3.3.3操作分析
同样按照原始c程序中的顺序分析逐个操作在汇编中的实现。
1、控制转移:原始c程序中涉及了if(argc!=4)的条件判断,汇编文本中,通过cmpl比较来设置条件码,然后用je指令判断是否进行相应的跳转,这里je是相等就跳转,与原始的!=正好相反,所以是跳转到对立的分支部分。这是编译之后的一点不同,还有就是,原始的c程序只有一个if语句,而汇编代码里有许多跳转(图中没有完全显示),这些都是编译器处理后的结果。
总的来说,虽然编译后的汇编代码程序实现方式上与原始的c程序有些差异,但它们最终的功能是一样的。
图3.3.3.1控制转移
2、函数调用:原始的c程序调用了exit、printf、atoi和getchar函数,可以看到在汇编代码中,它们通过一条简单的call指令简单的实现了,call后面是函数名。函数的参数提前保存在对应的寄存器中(具体顺序为从参数1到参数6为rdi,rsi,rdx,rcx,r8,r9),并通过寄存器来传递,当参数大于6个时,通过栈来传递多出的参数。
图3.3.3.2函数调用
3、循环:原始程序中有for(i=0;i<9;i++)的循环操作,从图中可以看出,在汇编指令中,通过cmpl指令判断循环的边界条件,然后根据条件码的结果跳转到指定部分,不过这里有一点差异就是,原始是判断i<9而这里用的是等价的i<=8操作。
图3.3.3.3循环实现
4、赋值操作:原始的i=0赋值操作,在汇编指令中通过一条简单的mov传送指令实现,不过由于i保存在栈中,所以赋值地址为-4(%rbp)。
图3.3.3.4赋值操作
5、运算操作:对于循环变量i的自增操作,汇编文本中通过每次循环结束后addl指令将i的值每次加一实现。
图3.3.3.5 i++运算操作
6、数组元素获取:对于原始程序中的argv[1]、argv[2]这种的获取数组参数,汇编指令中通过先加上适当的偏移值,取出对应参数对应的地址后放入寄存器(如%rax),通过(%rax)的形式间接访问内存从而取得所需数组元素。由于在该环境下指针类型的大小为8个字节,所以对于第二个参数addq的立即操作数为16,第三个参数为8,每次间隔为size大小8。
图3.3.3.6数组元素的获取
7、类型转换:atoi即为一个类型转换的函数,将一个字符串转换成int类型的数据。
图3.3.3.7 atoi(argv[3])
编译成为:
图3.3.3.8 atoi(argv[3])
3.4 本章小结
本章介绍了编译的概念和作用,了解了编译的指令,并以hello为例重点分析了编译后的hello.s文件,通过从原始c程序出发,分别分析了各种数据和各种操作在汇编代码中的实现,我们会发现有许多同原始c程序的存在差异之处,但通过分析可以肯定的是汇编代码最终实现的功能是同原始程序保持一致的。
第4章 汇编
4.1 汇编的概念与作用
1、概念:汇编指的是汇编器将.s 文件翻译成机器语言指令,把这些指令打包成一种叫可重定位目标程序的格式,并将结果保存在目标文件(后缀为.o)中的过程。
2、作用:汇编将前一步生成的汇编代码真正转化为了机器可读的机器代码(二进制代码),汇编生成的可重定位目标文件是二进制文件,而不再是文本文件了。
4.2 在Ubuntu下汇编的命令
汇编的命令:gcc -c hello.s -o hello.o
图4.2.1 汇编的命令
4.3 可重定位目标elf格式
1、获得hello.o的ELF格式的命令:readelf -a hello.o > hello.elf
图4.3.1生成ELF文件的命令
2、ELF文件分析:
hello.o实际上是以一种可重定位链接格式组织的,我们称之为ELF,ELF文件格式是用如图所示的方法设计的:
名称 | 内容 |
.text | 程序的机器代码 |
.rodata | 只读数据,例如跳转表 |
.data | 已初始化的全局和静态C变量 |
.bss | 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量 |
.symtab | 一个符号表,存放一些在程序中定义和引用的函数和全局变量的信息 |
.rel.text | .text节中需要被修正的信息 |
.rel.data | 被模块引用或定义的所有全局变量的重定位信息 |
.debug | 调试符号表 |
.line | 原始C源程序中的行号和.text节中机器指令之间的映射 |
.strtab | 一个字符串表(包括.symtab和.debug节中的符号表) |
1)ELF头:ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等等。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
图4.3.2ELF头
2)节头表:记录每个节的名称, 类型, 属性(读写权限), 在ELF文件中所占的长度对齐方式和偏移量。
图4.3.3节头表
3)重定位节:重定位条目告诉链接器在目标文件合并成可执行文件时如何修改这个引用.。offset是需要被修改的引用的节的偏移, 符号标识被修改引用应该指向的符号. type告知连接器如何修改新的引用, 加数是一个有符号常数, 一些类型的重定位要用它对被修改引用的值做偏移调整。
图4.3.4 重定位节
4)符号表:它存放程序中定义和引用的函数和全局变量的信息, 符号表不包括局部变量的条目,Num位符号的编号,Bind为符号类型,Name为符号的名字。
图4.3.5符号表
4.4 Hello.o的结果解析
1、通过在命令行输入指令objdump -d -r hello.o > hello.txt生成hello.o的反汇编代码到文件hello.txt中查看并与之前的hello.s进行对照分析:
图4.4.1生成hello.txt反汇编文件
2、对照分析:
1)机器语言构成:在hello.s文件中只有汇编指令,而在hello.txt中还有了指令对应的机器指令(以16进制显示),每一条汇编指令对应着一条机器指令,指令的长短视指令类型而有着不同的长度,指令包括了指令本身的编码和操作所需的立即数等。
图4.4.2机器语言构成
2)分支转移:在hello.s中分支跳转都是直接将跳转目标记为一个标号,形如.L2、.L3这种,而在hello.txt中跳转的目标为一个具体的地址。
图4.4.3分支转移
3)函数调用:在hello.s中函数调用时直接在call指令后面跟上了函数名,而在hello.txt中函数调用写入的是一个具体的地址,虽然因为是外部的函数还无法确定地址,但仍然以地址的形式写入,初始的都设置为e800000000,在objdump后的hello.txt中还提示了重定位的信息,这些信息实际位置不是在.text段,不过这样显示我们可以方便的看到对于未解析符号的重定位信息。
图4.4.4函数调用
总的来说,hello.s和hello.txt差别并不大,基本遵循着一条汇编指令对应着一条机器指令,但二者在分支控制和函数调用上有着一些差异,具体来说,就是将一些符号对应着的地址初始化了一下,虽然没有链接之前还不知道具体地址信息,但也去除了原本的简单的符号表示,毕竟机器语言不可能直接写成符号。
4.5 本章小结
本章介绍了汇编的概念和作用,了解了汇编的命令,通过实际分析ELF格式文件的内容了解了可重定位目标文件的具体内容形式,最后通过对照分析原始的汇编代码hello.s和反汇编后的hello.txt,我们了解了汇编语言与机器语言之间的差异和对应关系。
第5章 链接
5.1 链接的概念与作用
1、概念:链接是将各种代码和数据片段收集并合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
2、作用:链接对于程序编写有重大的意义,尽管它的过程似乎显得枯燥无趣。它将不同的数据段整合到一起,这让文件的分离编译成为可能。有了它的存在,编写程序时可以将项目细分为不同的子项,更好的管理模块。
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.2.1链接的命令
5.3 可执行目标文件hello的格式
在终端输入readelf -a hello > linkhello.elf得到文本文件:
图5.3.1生成文本文件
1、ELF头:与上一个阶段的ELF文件相差不大,主要是看到文件的Type发生了变化,从REL变成了EXEC(Executable file可执行文件),节头部数量也发生了变化,变为了27个。
图5.3.2ELF头
2、节头表:节头部表对hello中所有信息进行了声明,包括了大小(Size)、偏移量(Offset)、起始地址(Address)以及数据对齐方式(Align)等信息。根据这些信息,我们能计算每一个节所在的位置。
图5.3.3节头表
3、程序头:程序包含八个type:
1) PTDR: 指定程序头表在文件及程序内存映像中的位置和大小.
2) INTERP: 指定要作为解释程序调用的以空字符结尾的路径名的位置和大小.对于动态可执行文件,必须设置此类型。
3) LOAD: 指定可装入段,通过p_filesz和p_memsz进行描述.文件中的字节会映射到内存段的起始位置.
4) DYNAMIC: 指定动态链接信息.
5) NOTE: 指定辅助信息的位置和大小.
6) GNU_STACK: 权限标志,标志栈是否是可执行的.
7) GNU_RELRO: 指定在重定位结束之后那些内存区域是需要设置只读.
图5.3.4程序头
4、Dynamic section
图5.3.5 Dynamic section
5、重定位节:重定位表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。列如全局变量,调用函数等信息需要被重定位,它们的位置需要被修正。
图5.3.6重定位节
6、符号表:存放着符号的名称、偏移等信息。
图5.3.7符号表
5.4 hello的虚拟地址空间
使用edb载入我们的hello程序,使用data dump查看虚拟空间,如图所示:
图5.4.1 hello的虚拟地址空间
通过data dump可以看到加载到的虚拟地址的程序代码,可以看到代码的起始地址空间为0x4000000。
图5.4.2ELF中.dynstr节信息
图5.4.3 虚拟地址空间中的对应位置
可以看到,可以根据elf文件中的对应节的信息找到对应的虚拟地址空间中的指定位置。同样可以找到其他的节的位置。
5.5 链接的重定位过程分析
通过键入命令objdump -d -r hello > hello_1.txt生成hello可执行文件的反汇编结果:
图5.5.1生成hello_1.txt反汇编文件
1、函数:链接后的可执行文件的反汇编文本中多出了许多之前只有符号没有定义的函数部分,比如puts、printf等,多出了这些要使用的函数的实际代码是因为动态链接器将要用到的函数动态链接加入到了可执行文件中。
图5.5.2函数部分
2、函数调用指令:可执行文件由于经历了链接,函数调用时使用的是实际的函数地址而不是初始化的0,而这都是因为链接时符号解析和重定位的结果。
图5.5.3函数调用
3、分支跳转:同样跳转指令的参数发生了变化,这也是由于链接器解析了重定位条目,然后修改了对应的位置值,链接器通过计算设置出了正确的跳转的相对地址。
图5.5.4分支控制
总的来说,链接器完成的两个主要任务:符号解析和重定位。
重定位由两步组成:1. 重定位节和符号定义。 2. 重定位节中的符号引用。
在重定位时,链接器根据重定位条目,先找出要重定位的位置,再根据重定位的信息,如类型(绝对地址还是相对地址等)、修正值等计算出正确的要修改的结果填充到原本只是简单填充的部分,从而完善了指令,当重定位完所有条目后得到了最终的可执行文件,这里给出教材中的计算公式:
图5.5.5重定位计算
5.6 hello的执行流程
使用edb的symbol viewers工具很方便的调试查询跳转的地址:
图5.6.1查看函数地址
可以得到执行过程:
①开始执行:_start、_libc_start_main
②执行main:_main、_printf、_exit、_sleep、_getchar
③退出:exit
程序名称 | 程序地址 |
ld-2.31.so!_dl_start | 0x00007f807a1180b3 |
ld-2.31.so!_dl_init | 0x00007f5a70281c10 |
Hello!_start | 0x000000000040111e |
libc-2.31.so!__libc_start_main | 0x00007fd1c3e7a550 |
Hello!main | 0x00000000004010d0 |
Hello!printf@plt | 0x00000000004010a0 |
hello!atoi@plt | 0x00000000004010c0 |
Hello!sleep@plt | 0x00000000004010e0 |
hello!getchar@plt | 0x00000000004010b0 |
libc-2.31.so!exit | 0x00007d1c3e6f460 |
5.7 Hello的动态链接分析
动态链接的基本思想是将程序分块,在运行时才结合成一个部分。当然,这会带来链接上的困难。计算机通过延迟绑定的方法加上全局偏移量表(GOT)和过程链接表(PLT)实现的,我们可以通过一个例子来看:
图5.7.1got的地址
图5.7.2调用前的内容
图5.7.3调用后的内容
编译器不知道动态链接的函数的运行时地址,等到动态链接后才可以得到最终的函数地址,至此该位置也就确定了,不会再改变。为了避免运行时修改调用模块的代码段,链接器实行延迟绑定策略,利用过程链接表PLT和全局偏移量表GOT实现动态链接。通过在GOT中存放函数的目标地址,PLT使用GOT中的地址跳转到目标函数,在加载时动态链接器会重定位GOT中的条目,使得它包含正确的绝对地址。
5.8 本章小结
本章主要介绍了连接的概念与作用,并对链接生成的ELF文件格式和连接过程了分析,列如虚拟空间,重定位等等,展现了重定位在链接过程中的重要性。
第6章 hello进程管理
6.1 进程的概念与作用
1、概念:进程是指一个执行的程序得实例。或者说是一个拥有某个功能的数据集合的活动。它是操作系统中最基本的执行单元与操作单元。
2、作用:进程的概念为我们提供了两个关键的抽象:
(1)一个独立的逻辑控制流:他提供一个假象,好像我们的程序在独占地使用处理器。
(2)一个私有的地址空间:他提供一个假象,好像我们的程序在独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
1、作用:Shell是一个交互型应用级程序,为使用者提供操作界面,接收用户命令,然后调用相应的应用程序。实质上是一个命令语言解释器。通过它我们可以实现与操作系统的交互。
2、处理流程:
1)从终端读入输入的命令;
2)将输入字符串切分获得所有的参数;
3)检查第一个命令行参数是否是一个内置的shell命令,如果是则立即执行;
4)如果不是内部命令,调用fork( )创建新进程/子进程执行指定程序;
5)通过execve函数将指定程序的上下文等信息覆盖掉原进程;
6)依据有无“&”符号,判断前台运行还是后台运行。
6.3 Hello的fork进程创建过程
在shell中输入./hello,shell判断不是内置命令,通过fork函数创建一个新的运行的子进程来加载并运行可执行文件hello。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、用户库以及用户栈。子进程还可以获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件,父进程和新创建的子进程之间最大的区别在于它们有不同的PID。fork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0.父进程与子进程是并发运行的独立进程。
6.4 Hello的execve过程
execve函数在新创建的子进程的上下文中加载并运行hello程序,且带参数列表argv和环境变量列表envp。只有发生错误时execve才会返回到调用程序。execve调用一次且从不返回。在execve加载hello之后调用启动代码,启动代码设置栈并将控制传递给新程序的主函数。
当main开始执行时,用户栈组织结构如图:
图6.4.1用户栈组织结构
6.5 Hello的进程执行
进程有着两个关键抽象,它们与进程的执行息息相关,而要实现这两个抽象,与多进程逻辑控制流有关,而这种并发机制离不开内核的调度与上下文的切换。通过内核调度,每个进程轮流执行自己流的一个时间片,当该时间片用尽时,内核会通过切换上下文来切换进程,这样一直执行,直到进程们一个个执行结束。
1、逻辑控制流
所谓逻辑控制流就是进程执行时对应的一系列PC值,它反映了进程的执行所实际包含的指令序列,这些指令可能是程序本身的指令,还可能是动态链接后共享对象中的指令。对于单核处理器,每个进程执行它流的一部分后被暂时挂起然后执行其他进程,这样你来我往,交替执行,实现了并发的效果。
2、内核模式
内核模式区别于用户模式,它拥有着更高的权限,可以访问一切资源以及执行一切指令集中定义了的指令。在进程执行过程中,正常是运行在用户模式,但一旦需要发生进程切换,就需要内核来调度,进行上下文的切换。
3、上下文切换
内核为每个进程维持一个上下文。上下文就是内核重新启动一个进程所需的状态。这个状态包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
4、内核调度进程:本质就是通过切换上下文来实现的,通过切换上下文实现不同的状态来继续进行不同的进程。
5、时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
本实验中hello的运行从抽象来看是我们的处理器唯一地执行着hello程序直到结束,但其实是:hello在用户模式执行一段时间,当其时间片用尽(处理器判断它已经执行了相当的一段时间),系统就会保存起hello进程的上下文,暂时暂时挂起该进程,进入内核模式切换上下文开始执行其他进程,一段时间后当再次需要执行hello(内核调度机制判断)时,进入内核模式调度hello,恢复其上下文,重新执行hello,循环往复,直到最终hello进程彻底结束。
6.6 hello的异常与信号处理
1、异常种类:异常并非错误,而是指控制流的突变,主要分为以下四种:
类型 | 原因 | 同步/异步 | 返回 |
中断 | 来自I/O设备的信号 | 异步 | 下一条指令 |
陷阱 | 有意的异常 | 同步 | 下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 当前指令或终止 |
终止 | 不可恢复的错误 | 同步 | 终止 |
1)中断:往往是外界信号的输入,例如键盘输入。执行过程如图所示:
图6.6.1中断
2)陷阱:有意的异常。由于异常是系统进入内核模式的唯一方式,所以陷阱往往被用来当做进程与内核之间的一个接口。
图6.6.2陷阱
3)故障:潜在可恢复的错误。当故障发生时,处理器将控 制转移给故障处理程序,如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它,否则,终止它。
图6.6.3故障
4)终止:严重的不可恢复的错误。程序将直接终止。
图6.6.4终止
2、信号:信号就是一条小消息,他通知进程系统中发生了一个某种类型的事件。系统分析这些信号,并判断发生了什么并按照事先处理好的信号处理程序执行。
图6.6.5信号类别
3、信号处理:
当内核把进程p从内核模式切换到用户模式时,它会检查进程p的未被阻塞的待处理信号的集合。如果集合非空,内核强制p接收信号k。收到这个信号会触发进程采取某种行为,一旦完成行为,控制就传递回p的逻辑控制流中的下一条指令,每个信号类型都有一种默认行为,可以通过设置signal函数改变和信号signum相关联的行为。
1)正常执行:程序结束后,被正常回收
图6.6.6 正常执行
2)多按回车:程序执行时多按回车只会多一些空行,程序仍然正常执行
图6.6.7多按回车
3)Ctrl-C:按下Ctrl-C后进程收到SIGINT信号,shell终止并回收掉hello进程。
图6.6.8 Ctrl-C
4)Ctrl-Z:按下Ctrl-Z后进程收到SIGTSTP信号后停止hello进程将其暂时挂起。
图6.6.9 Ctrl-Z
5)Ctrl-Z后运行其他命令
a、ps指令:挂起进程后ps查看发现hello确实作为进程仍然存在着。
图6.6.10挂起后ps
b、jobs指令:挂起后用jobs查看会发现hello这一被停止的作业,其作业编号为1,处于挂起状态。
图6.6.11挂起后jobs
c、pstree指令:挂起后通过pstree指令查看所有进程信息。
图6.6.12挂起后pstree
d、fg指令:挂起后通过fg命令可以继续执行之前的进程。
图6.6.13挂起后fg
e、kill指令:挂起进程后,首先可通过jobs发现hello确实被挂起,然后通过ps查看其pid,然后通过kill -9 3863将信号9发送到pid为3863的进程(也就是hello进程),该信号默认杀死进程,所以当我们再次通过ps查看进程信息时,发现原来的hello进程确实被杀死了。
图6.6.14挂起后kill
6.7本章小结
本章介绍了进程的概念和作用,通过依次介绍程序加载和执行的过程,展现了程序通过shell执行的本质,最后通过以各种方式实际运行hello程序,展现了shell对于各种异常和信号的处理情况并通过简单的分析介绍了产生结果的原因。
第7章 hello的存储管理
7.1 hello的存储器地址空间
在程序编译过程中,编译器将hello.c源代码转换成机器指令,并生成一个可执行文件hello.o。可执行文件中包含了多个段,如代码段、数据段、堆栈段等。这些段在内存中的位置是不同的,因此需要使用不同的地址来标识它们。
1、逻辑地址:hello程序中使用的地址。逻辑地址是相对于代码段的偏移量,由CPU生成的,CPU通过地址转换机制将逻辑地址转换为线性地址。
2、线性地址:逻辑地址经过地址转换机制转换后的地址。地址转换机制是由操作系统提供的,它将逻辑地址转换为线性地址,并将线性地址映射到物理地址。线性地址是虚拟地址,因为它是相对于虚拟内存的偏移量。
3、虚拟地址:在操作系统中,每个进程都有自己的虚拟地址空间,虚拟地址空间是由操作系统分配的。虚拟地址空间是逻辑地址和线性地址的总和,它包括了代码段、数据段、堆栈段等多个段。虚拟地址是相对于虚拟内存的偏移量,它是由操作系统提供的,用于隔离不同进程的地址空间。
4、物理地址:也称为实际地址,是指最终在物理内存中的地址。当CPU通过地址转换机制将线性地址转换为物理地址时,会根据内存管理单元(MMU)提供的映射表将线性地址映射到物理地址。物理地址是实际的内存地址,它是由硬件提供的。在hello程序执行过程中,CPU会根据物理地址来访问内存。
因此,在hello程序执行过程中,CPU会根据逻辑地址访问代码段、数据段等多个段,操作系统会将逻辑地址转换为线性地址,并将线性地址映射到物理地址。最终,CPU会根据物理地址来访问内存,执行程序。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel CPU使用段式管理机制将逻辑地址转换为线性地址。段式管理是一种基于段的地址转换方式,每个段有一个基地址和一个限长,用来描述一个连续的地址空间。在段式管理机制中,逻辑地址由段选择器和段内偏移量组成,而线性地址由段基地址和段内偏移量组成。
段选择器是一个16位的寄存器,它用来指定要访问的段。当CPU访问内存时,会将段选择器和段内偏移量组成逻辑地址,然后将逻辑地址送入地址转换机制进行转换。地址转换机制首先根据段选择器从段描述符表中获取段描述符,然后检查访问权限是否满足要求。如果权限满足要求,就将段基地址和段内偏移量组成线性地址,然后进一步转换为物理地址。
在段式管理机制中,每个段的大小可以是从1字节到4GB字节不等,因此可以灵活地管理内存。但是,段式管理机制的缺点是容易产生内存碎片,因为每个段的大小是固定的,导致有些段只使用了部分空间。此外,段式管理机制还存在一些安全问题,例如缓冲区溢出漏洞等,这些问题需要通过其他手段进行解决。
7.3 Hello的线性地址到物理地址的变换-页式管理
在Intel CPU中,线性地址到物理地址的变换是通过页式管理机制实现的。页式管理是一种基于页的地址转换方式,将线性地址划分为大小相等的页,每个页有一个页号和一个页内偏移量,用来描述一个连续的地址空间。在页式管理机制中,线性地址由页目录表、页表和页内偏移量组成,而物理地址由页框号和页内偏移量组成。
页目录表和页表都是由操作系统维护的数据结构,用来描述虚拟地址到物理地址的映射关系。页目录表包含了多个页表的基地址和访问权限等信息,而页表包含了多个物理页框的基地址和访问权限等信息。
当CPU访问内存时,会将线性地址送入地址转换机制进行转换。地址转换机制首先根据线性地址的高10位从页目录表中获取页表的基地址,然后根据线性地址的中间10位从页表中获取页框号。最后将页框号和线性地址的低12位组成物理地址。
在页式管理机制中,每个页的大小通常为4KB或者2MB,因此可以灵活地管理内存。页式管理机制的优点是使用效率高,内存利用率高,不容易产生内存碎片。此外,页式管理机制还可以通过设置页面属性来实现内存保护、共享和交换等功能,提高系统的安全性和可靠性。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是CPU中的一个高速缓存,用于存储最近访问的页表项。在CPU访问内存时,会将线性地址送入地址转换机制进行转换,如果TLB中存在对应的页表项,就可以直接从TLB中获取物理地址,从而加速地址转换的过程。如果TLB中不存在对应的页表项,就需要通过访问页表来获取物理地址。
在四级页表支持下,地址转换的过程如下:
CPU将线性地址送入地址转换机制进行转换,首先从最高级的页目录表中获取页表的基地址,然后根据线性地址的三级页表索引从页目录表中获取二级页表的基地址,再根据线性地址的二级页表索引从二级页表中获取一级页表的基地址,最后根据线性地址的一级页表索引从一级页表中获取页表项。
如果TLB中存在对应的页表项,就可以直接从TLB中获取物理地址,否则需要将页表项从内存中读入TLB中,并从TLB中获取物理地址。
从页表项中获取页框号和页内偏移量,将页框号左移12位并加上页内偏移量,得到物理地址。
最后,CPU根据物理地址来访问内存。
TLB的作用是加速地址转换的过程,减少对内存的访问次数,从而提高系统的性能。
7.5 三级Cache支持下的物理内存访问
在三级Cache支持下,物理内存的访问过程如下:
CPU发出一个物理地址,该物理地址包含了三个部分:标记、组索引和块偏移量。
首先,CPU将标记和组索引发送到L3 Cache中进行查找,如果能够在L3 Cache中找到对应的缓存块,就表示命中,直接从L3 Cache中读取数据,并返回给CPU。
如果在L3 Cache中没有找到对应的缓存块,就需要到L2 Cache中进行查找。在L2 Cache中查找的过程类似于在L3 Cache中查找,如果找到对应的缓存块,就将其读取到L3 Cache中,并从L3 Cache中返回给CPU。
如果在L2 Cache中也没有找到对应的缓存块,就需要到L1 Cache中进行查找。在L1 Cache中查找的过程类似于在L2 Cache和L3 Cache中查找,如果找到对应的缓存块,就将其读取到L2 Cache和L3 Cache中,并从L3 Cache中返回给CPU。
如果在L1 Cache、L2 Cache和L3 Cache中都没有找到对应的缓存块,就需要从内存中读取数据,并将其存储到L1 Cache、L2 Cache和L3 Cache中。在这个过程中,如果发现L3 Cache中的某个缓存行已经被占用,就需要根据一定的替换算法来选择要替换的缓存行,以便为新的数据腾出空间。
最后,将从L3 Cache中获取的数据返回给CPU。
在三级Cache支持下,CPU可以快速地访问缓存中的数据,从而提高系统的性能。
7.6 hello进程fork时的内存映射
当一个进程在执行fork()系统调用时,会创建一个新的子进程。在创建子进程的过程中,父进程的内存空间会被复制到子进程中,包括代码段、数据段、堆和栈等。
使用fork()创建子进程时,会复制父进程的页表,但是复制后的页表指向的物理页框是相同的,这意味着父进程和子进程共享同样的物理内存。但是,由于页表是独立的,因此每个进程都有自己的虚拟地址空间,即每个进程使用的虚拟地址都是相互独立的。
在fork()系统调用之后,父进程和子进程会各自拥有一份完全相同的程序代码和数据。但是,父子进程之间的存储空间是相互独立的,因此它们可以独立地修改它们各自的存储空间,而不会影响对方。
在fork()系统调用之后,父进程和子进程会共享一些资源,例如打开的文件、信号量等。这些资源是通过文件描述符等方式来实现共享的。
总之,当一个进程在执行fork()系统调用时,会创建一个与父进程完全相同的子进程,并复制父进程的内存空间。父进程和子进程之间共享物理内存,但是它们使用的虚拟地址是相互独立的,因此它们可以独立地修改它们各自的存储空间,而不会影响对方。
7.7 hello进程execve时的内存映射
图7.7.1execve时的内存映射
当一个进程在执行execve()系统调用时,会将一个新的程序加载到当前进程的地址空间中,并替换当前进程的代码段、数据段和堆栈等内容。在执行execve()系统调用时,操作系统会根据新程序的ELF格式文件,重新构建进程的内存映射。
首先,操作系统会为新程序分配一块新的地址空间,该地址空间包括代码段、数据段、堆和栈等。
然后,操作系统会将新程序的代码段、数据段和其他相关段加载到新分配的地址空间中,并建立相应的页表项。
接着,操作系统会将新程序的入口点设置为程序计数器,并将堆栈指针设置为栈顶地址,以便进程能够开始执行新程序。
此外,操作系统还会根据新程序的ELF格式文件,加载相关的共享库,并将其映射到进程的地址空间中。
最后,操作系统会关闭原来的文件描述符,并打开新程序需要的文件描述符。
总之,当一个进程在执行execve()系统调用时,会将一个新的程序加载到当前进程的地址空间中,并替换当前进程的代码段、数据段和堆栈等内容。操作系统会根据新程序的ELF格式文件,重新构建进程的内存映射,包括加载新程序的代码段、数据段和其他相关段,以及映射相关的共享库,并关闭原来的文件描述符,并打开新程序需要的文件描述符。
7.8 缺页故障与缺页中断处理
图7.8.1 缺页故障
缺页故障是指当CPU需要访问一个虚拟地址对应的物理地址时,发现对应的页表项不存在或者被标记为无效时,就会发生缺页故障。在这种情况下,操作系统需要将缺失的页从磁盘加载到内存中,并更新页表,以便CPU能够访问对应的物理地址。
缺页中断是指当发生缺页故障时,CPU会发出一个中断请求,让操作系统来处理缺页异常。当操作系统接收到中断请求后,会根据缺失的页的信息,从磁盘中读取缺失的页,并将其加载到内存中,然后更新页表,以便CPU能够访问对应的物理地址。
在处理缺页中断时,操作系统通常会采取以下几个步骤:
首先,操作系统会检查缺失的页是否在内存中的页缓存中,如果存在,就直接将其读取到物理内存中,并更新页表。
如果缺失的页不在内存中的页缓存中,就需要将其从磁盘中读取到内存中。在这个过程中,操作系统需要分配一个物理页框,并将磁盘上的数据读取到该页框中。
如果物理页框不够用,就需要使用页面置换算法来选择要替换的页框,以便为新的页腾出空间。
当新的页加载到内存中后,操作系统需要更新页表,以便CPU能够访问对应的物理地址。
最后,操作系统将CPU的执行流返回到发生缺页中断的指令处,让CPU重新执行该指令。
总之,当发生缺页故障时,CPU会发出一个中断请求,让操作系统来处理缺页异常,操作系统会将缺失的页从磁盘加载到内存中,并更新页表,以便CPU能够访问对应的物理地址。
7.9本章小结
本章主要讲述了hello程序的存储管理,介绍了在执行hello程序过程中内存地址的映射情况,并且如何正确的访存,并且介绍了在访存阶段可能出现的异常情况及解决办法。
结论
1、程序员通过IDE或者其他的文本编辑器创建了hello.c
2、文件hello.c被预处理器经过预处理调用外部库展开为hello.i
3、文件hello.i被编译器编译成hello.s,变为一个汇编语言程序
4、汇编器将hello.s汇编成机器码,把这些机器指令打包为hello.o,这个文件叫做可重定位的目标文件。
5、链接器将hello.o与动态链接库等链接成为可执行文件hello。
6、用户在shell中输入./hello 2021112002 唐家誉 1
7、在进程中shell调用fork函数创建子进程。
8、在这个子进程中,shell用函数execve来调用启动器加载器。
9、程序hello在一个时间片里执行自己的控制流,同时访问内存以及申请动态内存,并且可以接受ctrl+z,ctrl+c的信号
10.、hello调用sleep,getchar,exit等系统函数后进程结束,被父进程shell回收,内核删除为子进程创建的数据结构。
11、至此hello的一生结束了。
在这个过程中,高速缓存、多级页表等等工具协同工作,帮助运行程序。进程结束后,父进程回收子进程,至此,hello结束了它的一生。“麻雀虽小,五脏俱全”,我们的程序也是如此。虽然它功能简单,却也可以拥有与其他大型程序一样完整的、复杂而精美的独属于它的程序人生。
通过这次大作业,我了解了在系统上执行hello程序时,系统发生了什么以及为什么会这样。我也逐步了解到了在程序运行的每一步中,计算机各个部分的功能,同时了解了在面对各种异常时的处理机制,这将有助于我对计算机系统的深入理解。
附件
hello.c c语言源程序
hello.i hello.c预处理生成的文本文件
hello.s hello.i编译后得到的汇编语言文件
hello.o hello.s生成的二进制文件
hello.txt hello.o反汇编后得到的文本文件
hello_1.txt hello反汇编得到的文本文件
hello.elf hello.o的elf文件
hinkhello.txt hello的elf文件
hello hello的可执行文件
参考文献
[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.