计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L022305
班 级 2003008
学 生 XXX
指 导 教 师 吴锐
计算机科学与技术学院
2021年5月
本文介绍了hello的整个生命过程。利用gcc,gdb,edb,readelf,HexEdit等工具分析了hello从源程序开始,历经预处理、编译、汇编、链接的一系列步骤变为可执行文件的过程,即P2P的过程。同时还具体分析了hello在运行过程中涉及的进程管理、内存管理、IO管理到最后hello被回收,即020的过程。通过对hello这个简单程序的详细分析,我们能够更加深入地理解计算机系统。
关键词:计算机系统;编译;内存;进程;
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:
Hello程序是从一个高级c语言程序开始的,源程序经过预处理器得到另一个c程序hello.i,再经过编译器将文本文件hello.i翻译成文本文件hello.s,接下来,汇编器将hello.s翻译成机器语言指令,结果保存在hello.o中,链接器将hello.o和系统文件组合起来,创建了一个可执行目标文件hello。
020:
通过shell输入./shell,shell通过fork创建一个新的子进程,子进程通过 execve系统调用启动加载器。加载器删除子进程子进程现有的虚拟内存段,然 后通过mmp函数创建新的内存区域。CPU从虚拟内存中的.text,.data节取代码 和数据,调度器为进程规划时间片,有异常时触发异常处理程序。程序运行结 束时,父进程回收hello进程和它创建的子进程,内核删除相关数据结构。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows7/10 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位 以上;
开发与调试工具:Visual Studio 2010 64位以上;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.c:源代码
hello.i:预处理生成的文件
hello.s:编译后的汇编文件
hello.o:汇编后的可重定位目标文件
hello:链接后的可执行文件
hello.elf:hello.o的ELF格式
hello_out.elf:hello的ELF格式
hello.o.asm.txt:hello.o反汇编代码
hello_out_asm.txt:hello反汇编代码
1.4 本章小结
本章的主要内容是初步了解hello在系统中的生命周期,以及实验所需的软硬件环境和开发与调试工具
第2章 预处理
2.1 预处理的概念与作用
概念:
预处理器(cpp)根据以字符#开头的命令,修改原始的c程序。
作用:
- 将源文件中用#include声明的文件复制到新的程序中
- 用实际值替换用#define定义的字符串
- 根据#if后面的条件决定需要编译的代码
- 删除注释
- 保留#pragma编译器指令
- 添加行号和文件标示
2.2在Ubuntu下预处理的命令
命令:gcc -E hello.c -o hell.i
图2-1 预处理命令
2.3 Hello的预处理结果解析
经过预处理后,hello.c变为hello.i文件,文件中的行数大大增加,预处理对源程序的宏定义进行了宏展开,头文件中的内容被包含在hello.i中。代码中的#define命令也进行了替换。hello.i文件中,首先是拼接的各种库文件,hello.c包含的头文件中还包含其他头文件,因此系统会递归式的寻址和展开,直到文件中不含宏定义且相关的文件均已被引入。同时引进了头文件中所以typedef关键字,结构体类型、枚举类型、通过extern关键字调用并声明外部的结构体和函数定义。
图2-2 hello.i
图2-3 hello.i
图2-4 hello.i
程序的源文件在文件的末尾
图2-5 hello.i
2.4 本章小结
本章介绍了预处理的概念和作用,还有对其效果的分析,预处理可以使程序再后序操作中不受阻碍,是十分重要的步骤。
第3章 编译
3.1 编译的概念与作用
概念:
编译是指预处理后的文件到生成汇编语言程序的过程。主要包括五个阶段:词法分析、语法分析、语义检查、中间代码生成、目标代码生成。
作用:
- 词法分析:词法分析的输入是源程序,输出是识别出的记号流.目的是识别单词. 至少分以下几类:关键字(保留字)、标识符、字面量、特殊符号。
- 语法分析:语法分析: 输入是词法分析器返回的记号流,输出是语法树.目的是得到语言结构并以树的形式表示.对于声明性语句,进行符号表的查填,对于可执行语句,检查结构合理的表达式运算是否有意义。
- 语义分析:根据语义规则对语法树中的语法单元进行静态语义检查,如类型检查和转换等,目的在于保证语法正确的结构在语义分析上也是合法的。
- 目标代码生成:目标代码生成器把语法分析后或优化后的中间代码经汇编程序汇编生成汇编语言代码,成为可执行的机器语言代码。
3.2 在Ubuntu下编译的命令
命令:gcc -s hello.i -o hello.s
图3-1 编译命令
3.3 Hello的编译结果解析
- 整数和字符串常量
在语句if(argc!=4)中,常量4的值保存在.text中,作为指令的一部分,同理,for(i=0;i<8;i++)中的0、8也存储在.textprintf()、scanf()中的字符串存储在.rodata节中
- 变量
全局变量
以及初始化且初始值非零的全局变量存储在.data节,它的初始化不需要汇编语句,而是通过虚拟内存请求二进制为零的页完成的。
局部变量
局部变量存储在寄存器或栈中,程序中局部变量i被存储在栈中%rsp-4的位置上
- 赋初值
i=0是一个赋值语句
- 算术操作
在循环操作中使用了++操作符,对i自加,栈上存储变量i的值加1
- 关系操作和控制转移
程序判断传入的参数argc是否等于4
如果相等则跳转到.L2,其中je用于判断cmpl产生的条件码。for循环中的i<8也是,若i小于等于7就跳转到.L4,执行循环体中的内容。
- 数组/指针/结构操作
main函数中的参数char* argv[]是一个指针数组,第一个参数argv[0]是程序的名称,并且包含了程序所在的完整路径,以后的参数是命令行后面跟的用户输入的参数。char*类型的数据占8个字节,由汇编代码知,argv[1],argv[2]两个字符串的首地址是M[%rbp-32+16]和M[%rbp-32+8].
函数操作:
main函数:
参数传递:有两个参数,int argc 和char *argv[],给函数传参需要先设定寄存器,将参数传给所设的寄存器
函数调用:通过call指令调用其他函数,共有6个call指令
第一处调用printf转换为puts,把.LC0段的立即值传送给%rdi,然后跳转到puts;
第二处调用exit,先将立即数1传入%edi中,然后跳转
第三处调用printf,先将argv[1]传送给%rsi,将模式串传送给%rdi,然后跳转
第四处调用atoi,先将argv[3]传送给%rdi,然后跳转,返回一个整型数
第五处调用sleep,先将atoi返回的值传送给%rdi
第六处调用getchar,没有参数传递,直接跳转函数返回:返回0,通过设置%eax为0实现
3.4 本章小结
本章介绍了编译的概念和过程。通过hello函数分析了c语言如何转换成为汇编代码。介绍了代码如何实现变量、常量、传递参数以及分支和循环。
第4章 汇编
4.1 汇编的概念与作用
概念:
汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
作用:
将汇编代码转换为机器指令,使其在链接后能被机器识别并执行。汇编器翻译产生的机器指令会被打包成可重定位目标文件的格式存放在.o中。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o
readelf -a hello.o > ./hello.elf.txt
objdump -d -r hello.o >hello.out.asm.txt
图4-1 汇编命令
4.3 可重定位目标elf格式
ELF头:
包含了16字节标识信息、文件类型(.o,exec,.so)、机器类型(如IA-32)、节头表的偏移、节头表的表项大小以及表项个数等。
图4-2 ELF头
节头表:
记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
图4-3 节头
重定位节:
.rel.txt节:.text节的重定位信息,用于重新修改代码段中的指令的地址信息
.rel.txt节:.data节的重定位信息,用于对被模块使用或定义的全局变量进行重定位的信息
本程序需要重定位的是printf、puts、exit、atoi、sleep、getchar和.rodata中的模式串
图4-4 重定位节
符号表:
.symtab节存放函数和全局变量(符号表)信息,它不包括局部变量。一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
图4-5 符号表
4.4 Hello.o的结果解析
图4-6 hello.o反汇编代码
将反汇编结果与hello.s对照分析:
- 在数的表示上,hello.s中的操作数表现为十进制,而hello.o的反汇编代码中的操作数为16进制。此外,它省略了了很多指令结尾的‘q’,这些后缀是大小提示符,在大多数情况下可以省略。相反,反汇编器给call和ret指令添加了‘q’后缀,同样,省略这些后缀也没有问题。
- 在分支转移上,hello.s使用.LC0、.LC1等助记符,而在反汇编代码中,它使用目标代码的虚拟地址跳转,并留下了重定位条目,跳转地址为零,它们将在链接之后被填写在正确的位置。
- 在函数调用上,hello.汇编代码中直接跟函数名称,而在反汇编代码中使用虚拟地址,留下重定位条目。
4.5 本章小结
本章介绍了汇编的概念和作用,分析了ELF文件的内容,并对汇编代码和反汇编代码进行了比较,了解汇编语言翻译成机器语言的转换处理和两者间的映射关系。
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
概念:
链接(linking)是将各种代码和数据片段收集并合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(run time),也就是在由应用程序来执行。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
作用:
链接器在软件开发过程中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。
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的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
ELF头:
节头数量从14增加到25,文件类型由可重定位文件变为可执行文件,入口地址是一个确定的值0x4010f0。
图5-2 ELF头
节头表:
链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。节头表中包含了各段的基本信息,包括名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐等信息。
图5-3 节头
程序头表:
程序头表描述可执行文件中的节与虚拟内存中的映射关系。
Offset:所述段在文件中的偏移量(从起始位置计算,单位为字节);
VirtAddr:给出段的数据映射到虚拟地址空间中的位置;
PhysAddr:物理寻址支持;
FileSize:段在二进制文件中的长度;
MemSiz:段在虚拟内存中的长度,与文件物理长度的差值可以通过截断数据或填充0字节来补偿;
Flags:保存标志信息,定义了段的访问权限,R表示只读,E表示文件可执行,RW表示可读可写。
段的种类:
LOAD:表示可装入段,在文件执行前从二进制文件映射到内存。;
DYNAMIC:表示段包含了用于动态链接器的信息;
NOTE:指定一个段,其中可能包含专用的编译器信息;
图5-4 程序头表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图5-5 edb命令
程序头表描述可执行文件中的节与虚拟内存空间中的存储段之间的映射关系。用edb加载hello,data dump窗口可以查看加载到虚拟内存中的hello程序。
PHDR:第0x000040~0x00002cf字节,映射到虚拟地址0x400040~0x4002cf,大小为0x2a0字节,按0x1000对齐。
INTERP:第0x00002e0~0x00002fb字节,映射到虚拟地址0x40002e0开始长度为0x1c字节的区域。
第一可装入段:第0x0000000~0x00005bf字节,映射到虚拟地址0x4000000开始长度为0x5c0字节的区域。
第二可装入段:第0x0001000~0x0001244字节,映射到虚拟地址0x4001000开始长度为0x1245字节的区域。
第三可装入段:第0x0002000~0x000213b字节,映射到虚拟地址0x4002000开始长度为0x13c字节的区域。
第四可装入段:第0x0002e50~0x00030c3字节,映射到虚拟地址0x4003e50开始长度为0x1fc字节的区域。
DYMAMIC:第0x0002e50~0x0002fef字节,映射到虚拟地址0x4003e50开始长度为0x1a0字节的区域。
NOTE: 第0x0000300~0x000031f字节,映射到虚拟地址0x4000300开始长度为0x20字节的区域。
NOTE: 第0x0000320~0x000033f字节,映射到虚拟地址0x4000320开始长度为0x20字节的区域。
GNU_PROPERTY: 第0x0000300~0x000031f字节,映射到虚拟地址0x4000300开始长度为0x20字节的区域。
GNU_RELRO: 第0x0002e50~0x0002fff字节,映射到虚拟地址0x4003e50开始长度为0x1b0字节的区域。
下图列出了部分例子:
图5-6 edb查看虚拟内存
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。结合hello.o的重定位项目,分析hello中对其怎么重定位的。
命令:
图5-7 objdump指令
对比hello和hello.o
增加了.init,.plt.sec,.plt三个节,其中.plt.sec节中加入了在hello.c中用到的库函数,如exit,printf,sleep,getchar等,链接器会将共享库中函数的汇编代码加入hello.o中。hello实现了调用函数时的重定位,在调用函数时调用的地址已经是确切的虚拟地址。
重定位过程:
- 重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。计算每个定义的符号在虚拟地址空间中的绝对地址。
- 重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
图5-8 .init节、.plt节、.plt.sec节
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
单步执行,进入main函数,
跳转到0x401154
跳转到0x4011a5
跳转到0x40115d
进入循环体,调用其他函数,一次循环结束跳转回0x40115d,循环完调用getchar()函数(0x40115d),最后退出,返回
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
根据hello.out.elf文件可知,GOT起始表位置为0x404000,如图:
链接前:
链接后:
利用代码和数据段的相对位置不变原则计算变量的正确地址。而对于库函数,需要plt、got的协作。plt初始存的是一批代码,他们跳转到got所指示的位置,然后调用链接器,初始时got里面存的都是plt的第二条指令,随后链接器修改got,下次再调用plt时,指向的就是正确的内存地址。plt就能调转到正确的区域。
5.8 本章小结
本章了解了链接的概念作用,分析可执行文件hello的ELF格式及其虚拟地址空间,同时通过实例分析了重定位过程、加载以及运行时函数调用顺序以及动态链接过程,深入理解链接和重定位的过程。
第6章 hello进程管理
6.1 进程的概念与作用
(以下格式自行编排,编辑时删除)
概念:
异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之-一。
在现代系统上运行一一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序-一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向shell输人一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
作用:
进程提供给应用程序的两个关键抽象:
一个独立的逻辑控制流,它提供一个假象,好像我们程序独占地使用处理器。 一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell 是一种命令行解释器, 其读取用户输入的字符串命令, 解释并且执行命令. 它是一种特殊的应用程序, 介于系统调用/库与应用程序之间, 其提供了运行其他程序的的接口.它可以是交互式的, 即读取用户输入的字符串;也可以是非交互式的, 即读取脚本文件并解释执行, 直至文件结束. 无论是在类 UNIX, Linux 系统, 还是 Windows, 有很多不同种类的 Shell: 如类 UNIX, Linux 系统上的 Bash, Zsh 等; Windows 系统上的 cmd, PowerShell 等.
Bash 是 Bourne Again SHell 的缩写, 是 GNU 计划中的 Shell, 也是一些类 UNIX 系统与多数 Linux 发行版的默认 Shell。
shell-bash的处理流程:
命令的执行分为四大步骤 :输入、解析、扩展和执行
读入命令行。调用函数,解析以空格分隔的命令行参数,将其分隔成记号。检查一个记号是不是非关键字和是否是别名,接着进行大括号扩展、符号扩展、参数扩展、命令替换、算术替换、单词分隔、路径名扩展。接着进行命令寻址,分为函数、内置命令、可执行文件三种,最后运行命令。
对于hello,输入命令./hello后命令行构造argv和envp,调用fork()函数创建子进程,其地址空间与父进程shell完全相同,包括只读代码段、读写代码段、读写数据段、堆及用户栈等。再调用execve()函数在当前进程(新创建的子进程)的上下文中加载并运行hello程序。将hello中的.test节、.data节、.bss节等内容加载到当前进程的虚拟地址空间。调用hello程序的main函数,hello程序开始在一个进程的上下文中运行。
6.3 Hello的fork进程创建过程
linux的终端中始终运行这一个shell来执行用户输入的操作,作为用户与系统之家的媒介。在终端中输入./hello 120L022305 WYQ 3
shell解析输入的命令,判断是一个可执行文件,调用相应程序执行。
父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时。子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的PID。fork只被调用一次,却会返回两次,一次在父进程中,返回子进程的PID;一次在新创建的子进程中,返回0;
6.4 Hello的execve过程
execve函数在当前进程中载入并运行一个新程序: §
int execve(char *filename, char *argv[], char *envp[])
filename: 可执行文件(目标文件或脚本,用#!指明解释器,如 #!/bin/bash)
argv: 参数列表,指向一个以null结尾的指针数组惯例:argv[0]==filename
envp:环境变量列表,指向一个以null结尾的指针数组
系统为hello创建子进程后,子进程调用execve函数加载并运行可执行文件hello,并带参数列表argv和环境变量列表envp,将控制传递给main函数。
加载器将hello中的代码和数据从磁盘复制到内存,它创建类似Linux x86-64运行的虚拟内存映射。加载器在程序头表的引导下将hello的片复制到代码段和数据段,接下来加载器跳转到_start函数的地址,_start函数调用系统启动函数__libc_start_main(定义在libc.so)中,该启动函数初始化执行环境,处理main函数返回值,,在需要时把控制传回内核。
6.5 Hello的进程执行
下面先阐述逻辑控制流、并发流、时间片、用户模式和内核模式以及上下文的概念
逻辑控制流:
如果我们调试程序单步执行,就会看到一系列程序计数器(PC)的值,这些值唯一的对应于程序的可执行文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,简称逻辑流。
并发流:
一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发运行。流X和流Y互相并发,当且仅当X在Y开始之后和Y结束之前开始,或者Y在X开始之后和X结束之前开始。多个流并发地执行的一般现象被称为并发,也就是某进程开始执行以后(并未完成),PC跳转到其他进程执行。一个进程和其他进程轮流的运行的概念称为多任务。一个进程执行它控制流的一部分的每一时间段叫做时间片,因此,多任务也叫做时间分片。
用户模式和内核模式:
内核模式(超级用户模式),有最高的访问权限,可以执行任何指令,访问任何内存位置,甚至可以停止处理器、改变模式位,或者发起一个I/O操作。
用户模式,不允许执行特定权限的指令。
处理器使用一个寄存器当作模式位,描述当前进程的特权。进程从用户变为内核的唯一方法是中断、故障或者陷入系统调用时,才会将模式位设置成内核模式,得到内核访问权限,其他情况下都始终在用户权限中,就能够保证系统的绝对安全。
上下文:
内核为每个进程维持一个上下文,上下文就是重新启动一个被抢占的进程所需要的状态,包括寄存器、程序计数器、用户栈、内核栈和各种内核数据结构。
上下文切换:保存当前进程的上下文;恢复某个先前被抢占的进程被保存的上下文;将控制传给新恢复的进程。
接下来阐述进程调度的过程和用户态和内核态的转换。在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个之前被抢占了的进程所需的状态,这种决策叫调度。hello程序开始运行在用户模式,调用sleep()函数后进入内核模式,内核处理该函数,将hello进程从运行队列列入等待队列。hello休眠的计时器结束后发送信号给内核模式,内核执行中段,将hello重新添加进运行队列,继续执行。切换时要进行上下文切换,从上一个进程切换到下一个进程的上下文。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
异常包括中断、陷阱、故障、终止。
但在乱按过程中输入Ctrl-C时,会发送一个SIGINT信号给进程,该信号的默认行为是终止进程,调用ps,发现hello不出现。
在乱按过程中输入Ctrl-Z时,发送一个SIGSTOP信号给进程,该信号的默认行为是停止直到下一个SIGCONT,调用ps仍能看到hello,可使用fg命令将其调回前台继续运行。
其余字符不会导致依次,会进入缓存区。
6.7本章小结
本章介绍了进程的创建、执行和异常与信号处理。其间描述了fork()函数与execve()函数的作用,进程执行涉及到一些如上下文、用户模式、内核模式等概念。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:
指由程序产生的段内偏移地址。逻辑地址与虚拟地址二者之间没有明确的界限。
对于hello程序来说,它是出现在汇编代码中的地址。
线性地址:
指虚拟地址到物理地址变换的中间层,是处理器可寻址的内存空间(称为线性地址空间)中的地址。程序代码会产生逻辑地址,或者说段中的偏移地址,加上相应段基址就成了一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换产生物理地址。若是没有采用分页机制,那么线性地址就是物理地址。程序hello的代码会产生段中的偏移地址,加上相应段的基地址就成了一个线性地址。
虚拟地址:
是由程序产生的由段选择符和段内偏移地址组成的地址。这2部分组成的地址并不能直接访问物理内存,而是要通过分段地址的变化处理后才会对应到相应的物理内存地址。
物理地址:
指内存中物理单元的集合,他是地址转换的最终地址,进程在运行时执行指令和访问数据最后都要通过物理地址来存取主存。
7.2 Intel逻辑地址到线性地址的变换-段式管理
分段功能在实模式和保护模式下有所不同。
实模式:逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。
保护模式:线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。
段寄存器用于存放段选择符,通过段选择符可以得到对应段的首地址。处理器在通过段式管理寻址时,首先通过段描述符得到段基址,然后与偏移量结合得到线性地址,从而得到了虚拟地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页表是一个页表条目(PTE)的数组,虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。每个PTE由一个有效位和一个n位地址字段组成,有效位表明该虚拟页是否被缓存在DRAM中。如果设置了有效位,那么地址字段表示相应的物理页的起始位置;如果没有设置有效位,那么空地址表示虚拟页还未被分配,否则这个地址指向该虚拟页在磁盘的起始位置。
MMU利用页表实现从虚拟地址到物理地址的变换。CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含一个p位的虚拟页面偏移VPO和一个n-p位的虚拟页号VPN。MMU利用VPN选择适当的PTE,如果这个PTE设置了有效位,则页命中,将页表条目中的物理页号和虚拟地址中的VPO连接起来就得到相应的物理地址。否则会触发缺页异常,控制传递给内核中的缺页异常处理程序。缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。缺页处理程序调入新的页面,并更新内存中的PTE。处理程序返回到原来的进程,再次执行导致缺页的指令,CPU将引起缺页的虚拟地址重新发送给MMU。
图7-1 使用页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存,其中的每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。用于组选择和行匹配的索引和标记字段是从虚拟地址的虚拟页号中提取出来的。由于VA到PA的转换过程中,需要使用VPN确定相应的页表条目,因此TLB需要通过VPN来寻找PTE。和其他缓存一样,需要进行组索引和行匹配。如果TLB有2的t次方个组,那么TLB的索引TLBI由VPN的t个最低位组成,TLB标记(TLBT)由VPN中剩余的位组成的。
为了节省空间,会通过使用层次结构的页表来压缩页表。下面给出Corei7如何使用四级的页表来将虚拟地址翻译成物理地址:MMU 使用四级页表将 36 位 VPN 划分为 4 个 9 位的片 ,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址,VPN1提供L1 PET的偏移量,该PTE包含L2页表的基地址,VPN2提供L2 PTE的偏移量等等,以此类推。 使用四级页表可以节省内存,提高地址翻译的速度。
图 7-2 Core i7 第一级、第二级和第三级页表条目格式。每个条目引用一个 4KB子页表
图 7-3 第四级页表条目
图7-4 Core i7页表翻译
7.5 三级Cache支持下的物理内存访问
MMU将物理地址发送给L1缓存,并从物理地址中得出CT(缓存标记)、CI(缓存组索引)和CO(缓存偏移量)。根据缓存组索引找到L1缓存中相应的组,如果缓存标签为1,则直接从缓存中读取数据并根据缓存偏移量返回。如果缓存标签为0,不命中,再访问L2、L3和主存。
图7-5 Core i7地址翻译情况
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。内核给新进程创建虚拟内存,创建当前进程的mm_struct、区域结构和页表的原样副本,将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,为每个进程保持了私有地址空间的抽象概念。同时延迟私有对象中的副本直到最后可能的时刻,充分利用了稀有的物理内存。
7.7 hello进程execve时的内存映射
exceve()函数在当前进程的上下文中加载并运行我们需要的hello程序。用hello程序有效地替代当前程序,加载并运行hello需要以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域。为新程序(即hello)的代码、数据、bss和栈区域等创建新的区域结构。所有这些区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
(3)映射共享区域。如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器。最后设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
图7-6 加载器是如何映射用户地址空间的区域的
7.8 缺页故障与缺页中断处理
缺页故障的处理:缺页异常导致控制转移到内核的缺页处理程序。处理程序随后执行以下步骤:
- 判断虚拟地址是否合法。缺页处理程序搜索区域结构的链表,把虚拟地址和每个区域结构中的vm_start和vm_end做比较。如果指令不合法,缺页处理程序会触发一个段错误,从而终止这个进程。
- 判断内存访问是否合法。比如缺页是否由一条试图对只读页面进行写操作的指令造成的。如果访问不合法,缺页处理程序会触发一个保护异常,从而终止这个进程。
- 这时,内核知道缺页是由合法的操作造成的。内核会选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。处理程序返回时,CPU重新执行引起缺页的指令,这条指令将再次发送给MMU。这次,MMU能正常地进行地址翻译,不会再产生缺页中断了。
图7-7 Linux缺页处理
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但不失通用性,假设堆是一个请求二进制零的区域,紧接在未初始化数据区域后开始,向上生长。对每个进程,内核维护一个全局变量brk指向堆顶。
分配器将堆视为一组不同大小的块的集合来维护。每个块是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留,供应用程序使用;空闲块可用来分配。空闲块保持空闲,直到空闲块显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的(即显式分配器),要么是内存分配器自身隐式执行的(即隐式分配器)。 显式分配器和隐式分配器是动态内存分配器的两种基本风格,两者都要求应用显示的分配块。它们的不同之处在于由哪个实体来负责。显示分配器要求应用显示地释放任何已分配的块。例如,C标准库提供一种叫做malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。隐式分配器要求分配器检查一个已分配块何时不再被程序所使用,那么久释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫垃圾收集。
图7-8 堆
7.10本章小结
本章总结了hello运行过程中有关内存管理的内容。简述了TLB、多级页表支持下的地址翻译、cache支持下的内存访问、缺页的处理、fork+execve过程的内存映射以及动态存储分配的过程。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件,所有的I/O设备都被模型化为文件,甚至内核也被映射为文件
设备管理:unix io接口:所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O
8.2 简述Unix IO接口及其函数
Linux以文件的方式对I/O设备进行读写,将设备均映射为文件。对文件的操作,内核提供了一种简单、低级的应用接口,即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);
8.3 printf的实现分析
printf的源代码:
int printf(const char *fmt, ...){
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
printf函数是格式化输出函数, 一般用于向标准输出设备按规定格式输出信息。printf中调用了两个函数,分别为vsprintf和write。
vsprintf函数根据格式串fmt,并结合args参数产生格式化之后的字符串结果保存在buf中,并返回结果字符串的长度。
write函数将buf中的i个字符写到终端,由于i保存的是结果字符串的长度,因此write将格式化后的字符串结果写到终端。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar的源代码:
int getchar(void){
static char buf[BUFSIZ];
static char* bb = buf;
static int n = 0;
if(n == 0){
n = read(0, buf, BUFSIZ);
bb = buf;
}
return(--n >= 0)?(unsigned char) *bb++ : EOF;
}
getchar函数会从stdin输入流中读入一个字符。调用getchar时,会等待用户输入,输入回车后,输入的字符会存放在缓冲区中。第一次调用getchar时,需要从键盘输入,但如果输入了多个字符,之后的getchar会直接从缓冲区中读取字符。getchar的返回值是读取字符的ASCII码,若出错则返回-1。
8.5本章小结
本章介绍了Linux的IO设备管理方法和Unix IO接口及其函数,并分析了printf和getchar函数的实现。
结论
hello所经历的过程:
源程序:在文本编辑器或IDE中编写C语言代码,得到最初的hello.c源程序。
预处理:预处理器解析宏定义、文件包含、条件编译等,生成ASCII码的中间文件hello.i。
编译:编译器将C语言代码翻译成汇编指令,生成一个ASCII汇编语言文件hello.s。
汇编:汇编器将汇编指令翻译成机器语言,并生成重定位信息,生成可重定位目标文件hello.o。
链接:链接器进行符号解析、重定位、动态链接等创建一个可执行目标文件hello。
fork创建进程:在shell中运行hello程序时,shell会调用fork函数创建子进程,供之后hello程序的运行。
execve加载程序:子进程中调用execve函数,加载hello程序,进入hello的程序入口点。
运行阶段:内核负责调度进程,并对可能产生的异常及信号进行处理。MMU、TLB、多级页表、cache、DRAM内存、动态内存分配器相互协作,共同完成内存的管理。Unix I/O使得程序与文件进行交互。
终止:hello进程运行结束,shell负责回收终止的hello进程,内核删除为hello进程创建的所有数据结构。
感悟:
再简单的程序的运行,也涉及到一系列的复杂操作,需要硬件、操作系统、软件的相互协作配合,这些操作缺一不可,最终都在为使程序更快更好的运行而服务。
附件
hello.c:源代码
hello.i:预处理生成的文件
hello.s:编译后的汇编文件
hello.o:汇编后的可重定位目标文件
hello:链接后的可执行文件
hello.elf:hello.o的ELF格式
hello_out.elf:hello的ELF格式
hello.o.asm.txt:hello.o反汇编代码
hello_out_asm.txt: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.