赞
踩
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L021917
班 级 2003006
学 生 艾浩林
指 导 教 师 吴锐
计算机科学与技术学院
2021年5月
本文通过分析hello程序从C文件如何转变为可执行文件的全过程,包括预处理、编译、汇编、链接阶段,每一步如何对上一步形成的文件进行操作,形成新文件的过程。hello进程在shell执行的过程,存储管理的过程,I/O处理的过程。以这些过程的分析为例,阐明整个程序的生命周期。
关键词:hello;程序;预处理;编译;汇编;链接;进程;信号;异常;处理;内存;地址;I/O;计算机系统
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 5 -
5.3 可执行目标文件hello的格式........................................................................ - 8 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 10 -
6.3 Hello的fork进程创建过程......................................................................... - 10 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................... - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理.......................................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................ - 11 -
7.5 三级Cache支持下的物理内存访问............................................................. - 11 -
7.6 hello进程fork时的内存映射..................................................................... - 11 -
7.7 hello进程execve时的内存映射................................................................. - 11 -
7.8 缺页故障与缺页中断处理.............................................................................. - 11 -
8.2 简述Unix IO接口及其函数.......................................................................... - 13 -
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
1.1.1 Hello p2p: from program to process
Hello程序生命周期是由高级语言c程序开始的,而要让每条c语句都能被机器都懂并且被其他程序转化为一条低级机器语言指令,然后这些指令按照可执行目标程序格式打包好,并以二进制文件磁盘形式存放起来。在Unix中,需要从源文件到目标文件的转化:
gcc -o hello hello.c
调用编译器驱动程序,gcc编译器驱动程序读取hello.c,并把它翻译成一个可执行文件hello,而翻译过程分为四个阶段,由预处理器cpp读入hello.c文本,cpp根据#开头的命令,修改c程序,将stdio.h内容插入程序文本中得到hello.i。而后通过编译器(ccl)将文本文件hello.i每条语句翻译成低级机器语言指令成为文本文件hello.s,接下来汇编器(as)将hello.s每条语句翻译成低级机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中;最后,由于hello程序调用了printf函数(属于标准C库的一个函数),因此要将含printf函数的printf.o用链接器(ld)合并到我们hello.o程序中,结果得到hello文件(可执行文件)。
Linux系统中通过内置命令行解释器shell加载运行hello程序,为hello程序fork进程,至此,hello.c完成了P2P的过程。
1.1.2 020过程
shell首先fork一个子进程,然后通过execve加载并执行hello,映射虚拟内存,进入程序入口后将程序载入物理内存,进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。即,从0开始,以0结束,为020。
Windows10 位;VirtualBox/Vmware 11 以上;Ubuntu 16.04 LTS 64 位/ 优麒麟 64 位
1.2.2 软件环境
Windows7/10 64 位;VirtualBox/Vmware 11;Ubuntu 16.04 LTS 64 位/优麒麟 64 位
Visual Studio 2010 64 位;CodeBlocks 64 位;vi/vim/gedit+gcc
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名称 | 作用 |
hello.c | hello源文件 |
hello.i | 预处理后文本文件 |
hello.s | 编译得到的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 链接后可执行文件 |
hello.objdump | hello可执行文件反汇编代码 |
hello.elf | hello的elf文件 |
Hello_o.objdump | hello.o(链接前)的反汇编文件 |
hello_o_elf.txt | hello.o的ELF格式 |
本章介绍了hello.c源程序是如何在unix系统下由gcc编译器翻译成可执行目标文件hello,并且简单介绍hello的p2p以及020过程,列举了中间产生的文件及其作用
(第1章0.5分)
2.1.1预处理概念
预处理是编译第一步,将源程序文本.c文件读入cpp中并开始处理,c语言预处理指令如下:
预处理阶段的作用是让编译器在随后对文本进行编译的过程中,更加方便,因为访问库函数这类操作在预处理阶段已经完成,减少了编译器的工作。比如在这个hello.c文件中,预处理器cpp根据以字符#开头的命令,修改原始的c程序,告诉预处理器读取系统头文件stdio.h、unistd.h、stdlib.h等内容,并将其插入程序文本中,得到hello.i文件,进而进入下一步。
输入 gcc – E hello.c -o hello.i
得到如下图
图2.1通过gcc编译后得到hello.i文件
打开hello.c文件可以发现开头有四行注释,该程序引用了三个头文件
图2.2 打开hello.c文件
打开hello.i文件如下图
图2.3打开hello,i文件
可以看到,在c文件中原本注释好的内容已经被全部忽略掉了,对于stdio.h等头文件会直接将其插入到程序文件中
本章主要介绍了预处理的内容以及作用,并在Ubuntu中演示了hello.c生成hello.i的过程,展示了预处理后hello.i与hello.c的对比图,介绍了c语言c语言预处理指令
(第2章0.5分)
编译阶段是编译器(ccl)对hello.i文件进行处理,翻译成.s文件的过程。此阶段编译器会完成对代码的语法和语义的分析,生成汇编代码,并将这个代码保存在hello.s文件中。
主要作用:1.扫描(词法分析)2.语法分析3.语义分析4.源代码优化(中间语言生成)5.代码生成,目标代码优化。
5. 编译器后端主要包括:代码生成器:依赖于目标机器,依赖目标机器的不同字长,寄存器,数据类型等
gcc -S hello.i -o hello.s
如图,产生hello.s文件
图3.1
函数序言:保存调用%rbp信息并且为当前函数分配栈空间
图3.2
.file:文件名
.string:字符串
.globl:全局变量
.type:指定数据类型
.align:数据对齐。对齐方式:保证它后面的数据的起始位置是8的倍数
3.3.1 数据
图3.3
先将%rbp压入栈中,再将栈地址%rsp保存到%rbp中,对栈地址减去32,表示压栈,movl表示将该寄存器存储argc的值保存在%rbp-20地址中
传递参数列表: argv初始被放在寄存器%rsi中, 之后又将该%rbp(及栈地址)减去32,将argv首地址放在这里.
movl,由于定义i为整数类型,所以为4字,由于栈空间是栈底在上,栈顶在下,地址由高到低排列因此要在栈地址中减去4字节,并且将i存储在该地址中
图3.4
原c程序中有两段代码
int i;
for(i=0;i<8;i++)
这里直接将立即数存放在%rbp地址减去4中,所以在编写程序时,如果局部变量只在循环体内部调用的话,只需在循环体内临时定义变量即可.这里i也没有赋初值,所以编译时只在for循环的时候直接将立即数赋值到寄存器中去.
图3.5
这里有两条mov指令,分别将argc和argv保存到栈地址中
如图
图3.6
图3.7
这里体现了两种计算方式,第一种是整数算术操作,sub指令将右边的寄存器保存的值减去左边的立即数,另一种是直接在寄存器上进行运算操作,比如后面两行就是直接对寄存器的地址进行运算
图3.8
addl ,用一个立即数直接加上左边寄存器保存的值,联系上面分析,此处就是对循环变量i进行加一操作
源程序中有这样一段代码:
sleep(atoi(argv[3]));
由于sleepl函数要求参数传递整数类型,而argv是字符串类型,这里就涉及到数据类型转换了,而atoi就是将字符串nptr转化成int,在汇编指令里面观察如下:
图3.9
可以看到这里是直接调用了函数atoi进行数据类型转换
cmp指令只是进行比较,不会对寄存器的值有任何改动,比较的意义在于将两个操作数之差来设置条件码,根据比较结果来决定下一步跳转指令
观察源代码,可以发现一共有两处比较操作:
if(argc!=4){
for(i=0;i<8;i++){
在观察编译语句:
图3.10
这里是将立即数4与(%rbp)-20比较,根据上面的分析可知这个地址保存的是argc,所以对应着源程序argc!=4语句
图3.11
这条语句是将7和(%rbp)-4比较,由上面分析得知这个地址保存的是i的值,而此处表达的意思是将i与7进行比较看i是否小于等于7
图3.12 jump指令
跳转指令可以将程序跳转到其他部分.而跳转条件由前面比较给出的条件码决定,通过观察图3.10和图3.11可以发现,每次cmp后都会有jump指令,这些cmp将条件码设置好后就会决定下一步程序的跳转,跳转的地方有具体的标号,比如 .L4, .L2
图3.13 L2跳转
这几段连起来解释就是:先将4和argv比较,设置条件码后进入je,相等则跳转到L2,不相等则跳过je进入下一部分
数组操作一共出现了三处:
int main(int argc,char *argv[]){
printf("Hello %s %s\n",argv[1],argv[2]);
sleep(atoi(argv[3]));
第一处只是将函数形参传进main函数中,由于传入数组时只需要传数组的首地址,所以如图,直接将寄存器保存的值存到相应栈中即可
图3.14 argv数组保存
数组取址方式为首地址加上地址偏移量,偏移量大小为保存数据的大小,字符指针为8字节,所以计算方式是每次加8字节
图3.15 argv[2]读取方式
如图3.15 argv[2]读取方式是先将argv 首地址取出,在加上源地址偏移量,argv[2]是偏移两个单位,每次偏移据需要在地址加8字节,所以要对%rax保存地址值加16
图3.16 argv[1]读取方式
图3.17 argv[3]读取方式
argv[1]和argv[3]调用方式与argv[2]类似,都是对数组首地址加上偏移量
3.3.8函数操作
hello.c中涉及的函数操作有:
传递控制:
main函数因为被调用call才能执行(被系统启动函数__libc_start_main调用),call指令将下一条指令的地址压栈,然后跳转到main函数。
传递数据:
外部调用过程向main函数传递参数argc和argv,分别使用%edi和%rsi存储,函数正常出口为return 0,将%eax设置0返回。
分配和释放内存:
使用%rbp记录栈帧的底,函数分配栈帧空间在%rbp之上。恢复栈空间为调用之前的状态,然后ret返回,ret相当pop IP。
传递数据:
第一次printf将%rdi设置为“Usage: Hello 学号 姓名!\n”字符串的首地址。第二次printf设置%rdi为“Hello %s %s\n”的首地址,设置%rdx为argv[1],%rsi为argv[2]。
控制传递:
第一次printf因为只有一个字符串参数,所以call puts@PLT;第二次printf使用call printf@PLT。
传递数据:将%edi设置为1。
控制传递:call exit@PLT。
传递数据:将%edi设置为sleepsecs。
控制传递:call sleep@PLT。
控制传递:call gethcar@PLT
本章主要讲述了系统编译的作用,分析了hello.s汇编代码,从数据,赋值,类型转换到数组,控制转移等八个方面分析汇编代码的原理以及机制
(第3章2分)
汇编指的是汇编器ad将.s文件翻译成机器语言,并将其生成后缀名为.o的可重定位目标文件的过程。
将.s文件生成机器码,使其能够被链接器ld链接生成可执行文件。
输入gcc -c hello.s -o hello.o
图4.1 gcc -c hello.s -o hello.o
图4.2 利用edb打开hello.o文件
使用命令 readelf -a hello.o > hello_o_elf.txt,读取hello.o文件的ELF格式至hello_o_elf.txt中。
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
ELF头以一个16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行、共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。如图4-3所示。
图4-3 hello.o ELF头
节头部表包括节的全部信息,如图4-4所示,各个节的名称及内容如下:
节名称 | 包含内容 |
.text | 已编译程序的机器代码 |
.rela.text | 一个.text节中位置的列表,链接器链接其他文件时,需修改这些内容 |
.data | 已初始化的全局和静态C变量 |
.bss | 未初始化的全局和静态C变量和所有被初始化为0的全局或静态变量 |
.rodata | 只读数据段 |
.comment | 包含版本控制信息 |
.note.GNU-stack | 包含注释信息,有独立的格式 |
.symtab | 符号表,存放程序中定义和引用的函数和全局变量信息 |
.strtab | 字符串表,包括.symtab和.debug节中的符号表以及节头部中的节名字 |
.shstrtab | 包含节区名称 |
图4.4 hello.o 节头部表
重定位是将EFL文件中的未定义符号关联到有效值的处理过程。在hello.o中,对printf,exit等函数的未定义的引用和全局变量替换为该进程的虚拟地址空间中机器代码所在的地址。如图4-5所示。
图4.5 hello.o 重定位节
符号表(.symtab)是用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明。如图4-6所示。
图4.6 hello.o 符号表(.symtab)
输入objdump -d -r hello.o > hello_o.objdump
图4.7生成.objdump文件
打开如下
图4.8 hello_o.objdump与hello.s对比
汇编结果与反汇编区别主要在以下几个方面:
1. 立即数的引用不同,在反汇编中立即数是十六进制的,而汇编代码则是十进制。
2. 子程序的调用不同,反汇编代码中子程序的调用是通过对主函数地址的相对偏移进行的,具体原因是可重定位文件在静态链接前没有确定具体地址,所以采用相对于主函数偏移,在链接后直接为主函数确定地址就可以正常运行。而在编译代码中则是通过call直接加上函数名的方法进行的。
3. 分支跳转不同,在反汇编代码中,分支转移是主函数地址为基址,直接跳转到一个偏移地址中,而在汇编代码中则是通过 .L4、.L3这样通过段名称跳转的。
本章主要介绍了汇编的概念及其作用,通过演示介绍从.o文件到.elf可重定位目标文件的转换。从.o文件到.objdmp的转换展示了反汇编代码,又将汇编代码与反汇编代码的对比,展示了二者之间的区别与联系
(第4章1分)
5.1.1 链接的概念
链接是通过链接器(Linker)将每个模块中代码、数据(初始化全局变量、未初始化全局变量、静态变量、局部变量)完全连接形成可执行的目标文件,可执行文件可以被加载(复制)到内存并执行。
链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
连接命令:
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
(以下格式自行编排,编辑时删除)
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
使用readelf -a hello > hello.elf 命令,读取hello的ELF格式至hello.elf中。
图5.2 生成hello.elf过程
图5.3 典型elf可执行目标文件构成
5.3.1 ELF 头:
图5.4 hello.elf ELF头与hello_o.elf对比
对比发现两者差距不大,主要区别在入口点地址以及程序起点地址改变,由可重定位文件REL变为可执行文件EXEC,程序节头变多
5.3.2 节头表
图5.5 节头表
可执行文件中经过重定位每个节的地址不再是0,而是根据自身大小加上偏移量。节头部表描述不同节的位置和大小,目标文件中的每个节都有一个固定大小的节头部表条目。
5.3.3 程序头
图5.6 程序头
程序头部表描述了可执行文件的连续的片映射到连续的内存段的映射关系。包括目标文件的偏移、段的读写/执行权限、内存的开始地址、对齐要求、段的大小、内存中的段大小等。
5.3.4 符号表
图5.7 符号表
符号表存放程序中定义和引用的函数和全局变量的信息,一共51条
5.3.5 重定位节
图5.8 重定位节
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
使用edb加载hello,查看本进程的虚拟地址空间各段信息
图5.9 edb打开hello文件
可以看出,ELF开始地址是0x400000, 由5.3中的节头部表可以获得各个节的偏移量信息,从而得知各节在虚拟地址空间中的地址。
图5.10 edb symbols与节头对照关系
打开edb symbols窗口, 观察可发现,节头表偏移量加上起始地址0x400000就是其虚拟地址
objdump -d -r hello 分析hello与hello.o的不同
输入objdump -d -r hello > hello.objdump
图5.11 hello.objdump生成过程
图5.12 hello.o与hello反汇编文件对比
图5.13 相关库函数调用
重定位,通常来说把在装入时对目标程序中指令和数据地址修改的过程称为重定位。而静态重定位就是,在逻辑地址转换为物理地址的过程中,地址变换是在进程装入时一次完成的,以后不再改变。此处hello对hello.o重定位过程是链接器ld将所有链接文件中相同的节合并,并按照要求计算新的偏移地址赋值给新的节。同时链接器按链接指令的顺序搜索符号表,查找符号引用。
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
程序名称 | 程序地址 |
ld-2.27.so!_dl_start | 0x7fce:8cc38ea0 |
ld-2.27.so!_dl_init | 0x7fce:8cc47630 |
hello!_start | 0x400500 |
libc-2.27.so!__libc_start_main | 0x7fce:8c867ab0 |
-libc-2.27.so!__cxa_atexit | 0x7fce:8c889430 |
-libc-2.27.so!__libc_csu_init | 0x4005c0 |
hello!_init | 0x400488 |
libc-2.27.so!_setjmp | 0x7fce:8c884c10 |
hello!main | 0x400532 |
hello!puts@plt | 0x4004b0 |
hello!exit@plt | 0x4004e0 |
*hello!printf@plt | —— |
*hello!sleep@plt | —— |
*hello!getchar@plt | —— |
ld-2.27.so!_dl_runtime_resolve_xsave | 0x7fce:8cc4e680 |
-ld-2.27.so!_dl_fixup | 0x7fce:8cc46df0 |
–ld-2.27.so!_dl_lookup_symbol_x | 0x7fce:8cc420b0 |
libc-2.27.so!exit | 0x7fce:8c889128 |
共享库是解决静态库更新以及调用繁琐,在某个函数(比如printf)调用频率高的情况下造成内存浪费的产物,共享库在运行或加载时可以加载到任意位置,冰河一个内存的程序链接起来,这个过程就是动态链接。
hello程序对动态链接库的引用,基于数据段与代码段相对距离不变这一个机理,因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量。
图5.14 dl_init之前
图5.15 dl_init之后
可以看出执行dl_init后地址发生了变化。这些变化是因为编译器使用了延迟绑定技术。 Hello需要调用共享库函数,由于这些函数存放的地址可能发生变化,所以需要在程序运行时确定。
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
(第5章1分)
进程是一个执行中的程序的实例,系统中的每个程序都运行在某个进程的上下文中
一个独立的逻辑控制流,好像程序可以独使用处理器。
一个私有的地址空间,好像程序独占整个内存系统
6.2.1 Shell-bash的作用
shell和其他软件一样都是和内核打交道,直接服务于用户。但和其他软件不同,shell主要用来管理文件和运行程序。作为命令语言,它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令。
6.2.2 Shell-bash的处理流程
1. 从终端读入命令
2. 解析引用并分割命令行
3. 如果是内置命令立即执行;如果不是内置命令就认为输入的字符是一个路径,然后调用fork函数的execve,为该路径上的程序分配子进程去执行。
4. 接收输入的各种信号
5. 执行命令
6. 返回退出状态码
进程执行中调用一次fork,就创建一个子进程。Fork调用一次,返回两次,父进程返回子进程的PID,子进程返回0。父进程fork出子进程后,子进程得到与父进程完全相同但是独立的虚拟地址空间,包括用户栈、本地变量、堆以及全局变量值和代码段。两者并发执行。内核以任意方式交替执行它们的逻辑控制流中的指令。
Execve函数加载并运行可执行目标文件hello,并且带参数列表argv和环境变量列表envp,只有当错误出现时,例如找不到文件名称,execve才会返回到调用程序
Execve执行后,将调用启动代码,启动代码设置栈,并将控制传递给新程序的主函数,覆盖当前进程的代码、数据、栈。保留PID,继承已打开的文件描述符和信号上下文。
加载并运行hello需要以下几个步骤:
删除当前进程虚拟地址的用户部分中已存在的区域结构。
为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址中的共享区域。
设置当前进程上下文中的程序计数器,使之指向代码段的入口点。
6.5.1 进程上下文信息
内核为每个进程维持一个上下文。上下文就是内核重新启动一个被强占的进程所需的状态,这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件 描述符的集合。
6.5.2 进程时间片
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
6.5.3 进程调度
进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前抢占了的进程,这种决策就叫调度。是由内核中称为调度器的代码处理的。党内和调度了一个新的进程运行后,他将抢占当前进程,并使用上下文切换机制来讲控制转移到新的进程
6.5.4 用户态与核心态转换
为使操作系统内核提供一个独立的逻辑控制流和一个私有的地址空间,处理器必须提供一种机制来限制应用可以执行的指令和他可以访问的地址范围。
处理器通常使用一个寄存器的一个模式位提供两种模式的区分,该寄存器描述了进程当前享有的特权,设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置;当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。
这段代码中调用了sleep函数,函数的作用就是当运行到这一句的时候,程序会产生一个中断,内核会将这个进程挂起,然后运行其它程序,当内核中的计时器计时完毕后,会传一个信号给CPU,这时hello进程重新进入待执行进程队列中等待内核调度。
在执行到sleep函数的时候,切换到内核模式,将hello进程挂起,然后切换到用户模式执行其它进程。当到了2秒之后,发生一个中断,切换到内核模式,继续运行之前被挂起的进程。最后切换回用户模式,继续运行hello进程。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
hello执行过程中会出现的异常:
中断:信号SIGTSTP,默认行为是 停止直到下一个SIGCONT
终止:信号SIGINT,默认行为是 终止
终端输入 ./ hello 120L021917 aihaolin 1
中途乱按键盘不会影响进程
Ctrl^Z停止进程
在输入pstree获取进程树
图6.1 获取进程树
输入ps列出当前系统中的进程(包括僵死进程)
图6.2 获取系统进程
输入jobs获取当前shell环境中已启动的任务状态
图6.3 获取shell环境启动状态
运行时中断并且输入fg获取前台进程
图6.4 将进程调到前台
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
本章主要介绍了进程的概念及作用,描述了进程在计算机中的调用过程。介绍了shell的作用和处理流程,分析了hello程序运行时的fork和execve过程。分析了hello的进程执行和异常与信号处理过程。
(第6章1分)
逻辑地址是指由程序产生的与段相关的偏移地址部分。逻辑地址由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离。即hello.o里相对偏移地址。
线性地址是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生逻辑地址,或者说是(即hello程序)段中的偏移地址,它加上相应段的基地址就生成了一个线性地址。
虚拟地址是程序保护模式下,程序访问存储器所使用的逻辑地址称为虚拟地址,与实地址模式下的分段地址类似,虚拟地址也可以写为“段:偏移量”的形式,这里的段是指段选择器。就是hello里面的虚拟内存地址。虚拟地址存储在磁盘中。
物理地址(Physical Address)是指出现在CPU外部地址总线上的寻址物 理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么 hello的线性地址会使用页目录和页表中的项变换成hello的物理地址;如果没有启用分页机制,那么hello的线性地址就直接成为物理地址了。
一个逻辑地址由两部分组成,一共48位,包括前16位的段选择符以及后32位的段内偏移量。
每个段的首地址都存放在自己的段描述符中,而所有的段描述符都存放在一个描述符表中(描述符表分为全局描述符表 GDT 和局部描述符表 LDT)。而要想找到某个段的描述符必须通过段选择符才能找到。
段选择符是由一个16位长的字段组成,其中前13位是一个索引号。TI:0为全局描述符表(GDT),1为局部描述表(LDT)。Index指出选择描述符表中的哪个条目,RPL请求特权级。如图7-1所示:
图7-1 段选择符
被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址。
线性地址(即虚拟地址VA)到物理地址(PA)之间的转换通过分页机制完成。而分页机制是对虚拟地址内存空间进行分页。
系统将每个段分割为被称为虚拟页(VP)的大小固定的块来作为进行数据传输的单元,在linux下每个虚拟页大小为4KB,类似地,物理内存也被分割为物理页(PP/页帧),虚拟内存系统中MMU负责地址翻译,MMU使用存放在物理内存中的被称为页表的数据结构将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
如图,虚拟地址分为虚拟页号VPN和虚拟页偏移量VPO。通过页表基址寄存器PTBR+VPN在页表中获得条目PTE,一条PTE中包含有效位、权限信息、物理页号,如果有效位是0+空则代表没有在虚拟内存空间中分配该内存,如果是有效位0+非空,则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中,如果有效位是1则代表该内存已经缓存在了物理内存中,可以得到其物理页号PPN,与虚拟页偏移量共同构成物理地址PA。
图7-2 使用页表的地址翻译
7.4.1 TLB
每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个 PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这 会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1 中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样 的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓 存器(TLB)。
7.4.2 多级页表
将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由 上一级确定的页表基址对应的页表条目。
如图7-5所示 ,CPU产生虚拟地址VA,VA传送给MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)向TLB中匹配,如果命中,则得到PPN(40bit)与VPO(12bit)组合成PA(52bit)。
如果TLB中没有命中,MMU向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并且向TLB中添加条目。
如果查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
图7-3 Core i7四级页表下地址翻译情况
得到了物理地址VA,首先使用物理地址的CI进行组索引,对8路的块分别匹配 CT进行标志位匹配。如果匹配成功且块的valid标志位为1,则命中hit。然后根据数据偏移量 CO取出数据并返回。如果没有匹配成功或者匹配成功但是标志位是0,则不命中(miss),向下一级缓存中查询数据(L1 cahe ->L2 cache->L3 cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。如图7-4所示。
图7-4 三级Cache支持下的物理内存访问
当fork函数被shell调用时,内核为hello创建各种数据结构,并分配给它一个唯一的PID。为了给hello创建虚拟内存,fork创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello中返回时,hello现在的虚拟内存刚好和调用shell的虚拟内存相同。当这两个进程中的任何一个进行写操作时,写时复制机制会创建新页面。因此也就为每个进程保持了私有地址空间的概念。
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。图7-7描述了加载器映射用户地址空间区域的模型。加载并运行hello需要以下几个步骤:
删除当前进程虚拟地址的用户部分中的已存在的区域结构。
为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
图7-5 加载器映射用户地址空间区域
缺页故障是一种常见的故障,当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出时就会发生故障。故障处理流程如下。
图7-6 缺页故障处理流程
图7-7 缺页中断处理
1 处理器生成一个虚拟地址,并将它传送给MMU
2 MMU生成PTE地址,并从高速缓存/主存请求得到它
3 高速缓存/主存向MMU返回PTE
4 PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
5 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
6 缺页处理程序页面调入新的页面,并更新内存中的PTE
7 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用 free 函数来释放一个块。
隐式分配器(implicit allocator),要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector)。
本章讨论了存储器地址空间,虚拟地址、物理地址、线性地址、逻辑地址的概念,TLB与四级页表支持下的VA到PA的变换,三级Cache支持下的物理内存访问,hello进程fork时和execve时的内存映射,缺页故障与缺页中断处理和动态存储分配管理。
(第7章 2分)
8.1.1 设备的模型化:文件
一个Linux文件就是一个m字节的序列。
所有的I/O设备(网络、磁盘、终端),甚至内核都被映射为文件
8.1.2 设备管理:Unix I/O接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O设备管理方法。
将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、 低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
功能 | |
int open(char *filename, int flags, mode_t mode); | 打开文件 |
int close(int fd); | 关闭文件 |
ssize_t read(int fd, void *buf, size_t n); | 读文件 |
ssize_t write(int fd, const void *buf, size_t n); | 写文件 |
off_t lseek(int fd, off_t offset,int whence); | 在指定的文件描述符中将将文件指针定位到相应位置 |
ssize_t rio_readn(int fd, void *usrbuf, size_t n); ssize_t rio_writen(int fd, void *usrbuf, size_t n); | 直接在内存和文件之间传送数据 |
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen); ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n); | 内存和文件之间传送数据经过缓冲区 |
int stat(const char *filename, struct stat *buf); int fstat(int fd, struct stat *buf); | 检索到关于文件的信息 |
printf函数功能:接受字符串指针数组fmt,然后将匹配到的参数按照fmt格式输出。如下图,printf内部调用了两个函数,一个是vsprintf,一个是write。
图8-1 printf代码
其中,va_list是一个字符指针,arg表示 ‘…’的第一个参数。
vsprintf函数代码如图8-2所示:
图8-2 vsprintf代码
vsprintf的作用是格式化。接受确定输出格式的格式字符串fnt。用格式字符串对个数变化的参数进行格式化,产生格式化输出,并返回要打印的字符串的长度。
write函数代码如图8-3所示:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
图8-3 write代码
write函数中,先给寄存器传了几个参数,int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
sys_call函数代码如下
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
syscall函数将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)
getchar源代码如下:
getchar()函数实际上是int getchar(void),所以它返回的是ASCII码,所以只要是ASCII码表里有的字符它都能读取出来。在调用getchar()函数时,编译器会依次读取用户键入缓存区的一个字符(注意这里只读取一个字符,如果缓存区有多个字符,那么将会读取上一次被读取字符的下一个字符),如果缓存区没有用户键入的字符,那么编译器会等待用户键入并回车后再执行下一步
在程序中使用getchar()函数时应当使用回车符结束数据的输入,但是当用户输入的数据都保存在缓冲区内,包括回车符,于是在运行下一个getchar()函数时仍然从缓冲区读入数据就读到了输入第一个变量时留下的回车符,导致这个getchar无法接收到第二个变量的值,从而造成各种问题。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,
保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
本章简述了Linux的I/O设备管理机制,Unix I/O接口及函数,并简要分析了printf函数和getchar函数的实现
Hello程序的执行流程如下:
1. 预处理:预处理过程扫描源代码,对其进行初步的转换,产生新的源代码提供给编译器。预处理过程读入源代码,检查包含预处理指令的语句和宏定义,并对源代码进行响应的转换。预处理过程还会删除程序中的注释和多余的空白字符。
2. 编译:将源语言经过词法分析、语法分析、语义分析以及经过一系列优化后生成汇编代码 。
3. 汇编:将高级语言转化为机器可直接识别执行的代码文件,汇编器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位 目标程序的格式,并将结果保存在 .o目标文件中
4. 链接:是将各种代码和数据片段收集并组合为单一文件的过程,这个文件可以被加载(复制)到内存并执行
6. 进程管理:进程提供给应用程序两个关键抽象:一个独立的逻辑控制流;一个私有的地址空间。
7. 储存管理: 当fork函数被shell调用时,内核为hello创建各种数据结构,并分配给它一个唯一的PID。execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址
8. IO管理:通过printf与getchar函数实现hello的IO
用计算机系统的语言,逐条总结hello所经历的过程。
这门课程由浅入深地介绍了计算机系统的联合过程。从一小段最简单的代码hello world 开始,由一个hello.c文件开始向编译汇编再到链接,储存。一步步严丝合缝,让我深刻体会到计算机系统的精妙所在。不过我目前所学的仍是凤毛麟角,期待以后对计算机有更深刻的学习与理解
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
(结论0分,缺失 -1分,根据内容酌情加分)
列出所有的中间产物的文件名,并予以说明起作用。
文件名称 | 文件作用 |
hello.c | hello源文件 |
hello.i | 预处理后文本文件 |
hello.s | 编译得到的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 链接后可执行文件 |
hello.objdump | hello可执行文件反汇编代码 |
Hello_o.objdump | hello.o(链接前)的反汇编文件(与hello做对比) |
hello.elf | hello的elf文件 |
hello_o_elf.txt | hello.o的ELF格式 |
(附件0分,缺失 -1分)
为完成本次大作业你翻阅的书籍与网站等
[1] [转]printf 函数实现的深入剖析 - Pianistx - 博客园 printf 函数实现的深入剖析
[2] CSDN博客 - 专业IT技术发表平台 行缓冲——getchar()的内在原理
[3] PKjason.Bash命令行处理流程详解
[EB/OL].https://my.oschina.net/pkjason/blog/156458,2013-08-23
[4] Randal E. Bryant, David R. O'Hallaon. 深入理解计算机系统. 第三版. 北京市:机械工业出版社[M]. 2018: 1-737
[5] Linux的jobs命令_SnailTyan的博客-CSDN博客 Linux的jobs命令
[6] getchar(计算机语言函数)_百度百科 getchar (计算机语言函数)
[7] [转]printf 函数实现的深入剖析 - Pianistx - 博客园 [转]printf 函数实现的深入剖析
(参考文献0分,缺失 -1分)
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。