即使是最为简单的hello程序也历经坎坷,跌宕起伏,实现了P2P的逆风翻盘,历经艰辛--神秘--高贵--欣喜,最终hello--一个简单而又完美的生命就此诞生了!本文利用gcc,gdb,edb等一系列工具,分析了hello从源程序开始,经过预处理,编译,汇编,链接最终变为可执行文件的过程.同时也介绍了有关进程管理的知识,深入探讨系统底层软硬件结合的部分。通过对hello程序的分析,我们可以更加深入地了解计算机系统.
关键词:关键词1:预处理;关键词2: 编译;关键词3:汇编;关键词4:链接;
关键词5:进程管理。
目 录
第1章 概述
1.1 Hello简介
1.Hello的p2p意为从program到process。
Program:hello程序是从一个高级语言程序开始的,经过预处理器(cpp)处理变为了hello.i,然后在编译器(ccl)的处理下翻译为汇编程序hello.s,然后通过汇编器(as)将hello.s翻译成了机器语言指令,生成了一个二进制的可重定位目标程序hello.o,最后经过链接器(ld)的链接后生成了一个可执行目标文件hello.
图1-1 程序经历的过程
Process:当可执行目标文件hello在操作系统上运行的时候会被抽象为进程,给hello一个错觉:好像整个系统上只有它自己在运行。系统控制hello程序的运行是通过异常控制流,并且通过虚拟内存为hello程序分配空间,提供接口与其IO设备的通信,使得hello能完成自己的使命.
2.Hello的020意为从Zero-0到Zero-0:
通过shell输入./hello执行程序。Shell会利用fork函数创建一个新的子进程。
接着execve函数会在当前进程的上下文中加载并运行hello,然后会execve会删除子进程现有的虚拟内存段,通过mmp函数创建新的内存区域,并且建立地址空间到磁盘文件的映射,新的代码段和数据段会被初始化为hello的内容,再把编号写进虚拟地址空间和物理地址空间的映射表中.,调度器为进程规划时间片,当发现异常的时候触发异常处理程序.当程序运行结束时,父进程会回收hello进程及其子进程,内核删除其相关数据结构,从此刻开始,进程就不存在了,hello结束了它的一生.
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上.
软件环境:Windows10 64位以上;Vmware 11以上;Ubuntu 16.04 LTS 64位.
开发与调试工具:Visual Studio 2019 64位以上;CodeBlocks 64位;vim;gcc;gdb;edb.
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.c: hello的源文件.
hello.i:经过预处理后产生的文件.
hello.s:经过编译后产生的文件,包含一个汇编语言程序.
hello.o:经过汇编后产生的文件,是一个二进制可重定位文件.
hello:经过链接后产生的可执行目标文件.
hello.elf.text:hello.产生的elf文件
hellooasm..text:hello.反汇编后产生的文件
hello.text:hello产生的elf文件
1.4 本章小结
通过阅读hello的自白,形象的感受了hello经过编写、预处理、编译、汇编、链接和执行等阶段历程。说明了计算机系统在让一个程序运行不同的组成部分,一个进程P2P,020,体现了其中间状态,以及计算机分层次处理程序的思想
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理是对源文件编译之前所做的预备工作,是计算机在处理一个程序时所进行的第一步处理,可以进行代码文本的替换工作但是不会做语法检查.预处理能够对源程序文件中出现的以#开头的命令进行处理,包括宏定义#define,文件包含#include,条件编译#if,#else,#elif,#ifdef,等,最后将修改之后的文本进行保存,将源程序以及引用的库合并成完整的文件,最后得到了另一个以.i作为文件扩展名的程序.
预处理的作用:
预处理的作用是从系统的头文件包中将头文件的源码插入到目标文件中,在编译代码前首先将标识符替换好,确保程序的完整性,生成.i文件后再进行接下来的编译工作。
2.2在Ubuntu下预处理的命令
命令行:gcc -E hello.c -o hello.i.
图2-1 预处理命令
2.3 Hello的预处理结果解析
首先发现预处理生成的hello.i文件有三千多行.hello.i文件中,首先是拼接的各种库文件,hello.c包含的头文件中还包含其他头文件,因此系统会递归式的寻址和展开,直到文件中不含宏定义且相关的文件均已被引入。Hello.i文件中由例如typedef,struct,enum的对结构的定义,例如extern对外部变量的引用,以及对引用目录的标注.
图2-2 hello.i文件部分1
图2-3 hello.i文件部分2
最后发现文件末尾就是源程序:
图2-4 hello.i文件末尾源程序
2.4 本章小结:
本章节分析了从hello.c到hello.i预处理的过程,介绍了预处理的概念和作用。并且自己利用命令对文件进行了预处理操作,并且尝试读取解析预处理文件的内容,分析预处理到底进行了什么变化。
第3章 编译
3.1 编译的概念与作用
编译将hello.i翻译成hello.s.编译是对预处理文件进行词法分析,语法分析,语义分析,优化,转化为汇编语言程序.
1.词法分析:输入源程序,对构成源程序的字符串进行扫描和分解,识别出一个个的单词
2.语法分析:在词法分析的基础上,根据语言的语法规则,把单词符号串分解成各类语法单位
3.词义分析与中间代码的产生:对语法分析所识别出的各类语法范畴,分析其含义,并进行初步翻译(产生中间代码).
4.优化:优化的任务在于对前段产生的中间代码进行加工变换,以期在最后阶段能产生出更为高效(省时间和空间)的目标代码.
5.目标代码的生成:把中间代码(或经优化处理之后)变换成特定机器上的低级语言代码。这阶段实现了最后的翻译,它的工作有赖于硬件系统结构和机器指令含义。
命令行:gcc -S hello.c -o hello.s
图3-1 编译的命令
3.3 Hello的编译结果解析
1.数据:
(1)字符常量:
多为字符或字符串.LC0和LC1中分别为两个字符串的提示符,是只读的不可以改变.对应了print中的“用法: Hello 学号 姓名 秒数!\n”和“Hello %s %s\n”
图3-2 hello.s中的字符常量
.rodata声明了下面的内容是只读的,.text则是程序的代码段;.align 8说明本汇编语言程序是以八个字节的倍数来对齐的.
(2)局部变量
局部变量存储在栈中,通过机器语言指令赋值,当函数返回时,局部变量在栈中的空间会被释放.对应源程序中的int i;
图3-3 hello.s中的局部变量
(3)函数参数
函数参数即为形式参数,函数参数的本质也是局部变量,其只在调用的函数中作用。函数参数在调用的过程中也是被存储在栈中的,函数返回后,函数参数在栈上的空间也会被释放.对应 int argc 和 char *argv[]
图3-4 hello.s中的函数参数
2.表达式
(1)算数表达式:算数表达式是用一元二元运算符将操作数连接起来的表达式..
图3-5 hello.s中的算数表达式
在每次循环的后面都对局部变量i加1.对应于for循环中的i++.
(2)赋值表达式:使用MOV指令将一个值赋值给一个地址
图3-6 hello.s中的赋值表达式
对应于for循环中i = 0.
(3)判断表达式:利用CMP指令对两个值进行对比,根据结果跳转到所对应的地址.
图3-7 hello.s中的判断表达式(1)
对应于判断argc是否等于4,如果等于4则跳转到.L2
图3-8 hello.s中的判断表达式(2)
对应于循环关系式,如果i<=4则跳转到.L4
3.控制转移操作:
一般在CMP指令之后,根据不同的条件利用JUMP指令跳转到不同的目标.
图3-9 hello.s中的条件跳转
对应于如果argc!=4则跳转到.L2
图3-10 hello.s中的无条件跳转
此为无条件跳转,对应于令i=0时无条件跳转到.L3
图3-11 hello.s中的条件跳转(2)
对应于如果i<=4则跳转到.L4
4.数组操作:
Argv为字符串指针数组,有三个元素.数组元素是储存在堆栈中的,argv被存储在-32(%rbp)中,即为基地址
图3-12 hello.s中的数组操作
分别对应于argv[2],argv[1],argv[3].
第一个参数为基地址偏移8个字节,第二个参数为偏移16个字节,第三个参数为偏移24个字节
5.函数参数传递
参数传递:但参数个数小于等于6个时,编译器会将参数存储在寄存器当中传递,如果大于6个参数则多余的参数会在栈上传递.
图3-13 hello.s中的参数传递(1)
当输入的参数个数不是4个时,将提示字符串的地址赋给rdi,然后调用puts函数输出提示信息“Hello 学号 姓名 秒数”
图3-14 hello.s中的参数传递(2)
利用rdx,rsi,rdi三个寄存器分别传递第三个,第二个,第一个参数,接着调用printf函数输出"Hello %s %s\n",argv[1],argv[2].
6函数调用
(1)调用put函数printf函数
第5点已经说明清楚
(2)调用exit函数:(终止程序)
图3-15 调用exit函数
将1赋值给第一个参数,调用exit函数.对应于exit(1)
(3)调用atoi函数:(将字符串化为整数,如果成功则返回结果,失败则返回0)
图3-16 调用atoi函数
将argv[3]的值复制给%rdi,然后作为atoi的参数调用atoi函数
对应于atoi(argv[3])
(4)调用sleep函数(让函数进入x秒休眠,如果在休眠结束前停止则会返回剩余时间)
图3-17 调用sleep函数
将atoi函数的返回值存入%edi中,然后作为sleep的参数调用sleep函数
对应于sleep(atoi(argv[3]))
7.函数返回
图3-17 函数返回
此处的ret是从main函数当中返回。对应于return 0
3.4 本章小结
本章围绕hello.i经编译器处理得到hello.s的过程,介绍了编译的概念、过程并具体分析了hello程序的编译结果。通过分析汇编代码,我们更加清楚地明白了全局变量和局部变量的区别,常量是如何进行存储的,对于非线性执行的跳转语句是如何进行的,以及在实现跳转的基础上如何进行逻辑控制,实现循环。以及在跳转的基础上,了解了函数是如何进行调用的,调用另一个函数的时候如何进行参数的传递,以及这个过程中怎么对寄存器的内容进行保护等等。
第4章 汇编
4.1 汇编的概念与作用
汇编是指将汇编语言文件翻译为二进制机器语言文件的过程,机器语言文件被打包为可重定位目标文件的格式.
4.2 在Ubuntu下汇编的命令
命令行:as hello.s -o hello.o
图4-1 汇编的命令
4.3 可重定位目标elf格式
命令行:readlf -a hello.o > ./hello.elf.txt
图4-2 转为可重定位目标的elf格式文件的命令
1.ELF 头:以一个16字节序列开始,这个序列描述了生成该文件的系统的字大小和字节的顺序.如下图可知:数据以补码形式按照小端法存放,文件的类型是可重定位目标文件,系统架构为X86-64,入口点地址为0,程序头起点为0,节头表偏移量为1056字节,节头部表大小为64字节,数目为14,数组下表编号为0--13
图4-3 ELF头
- 节头部表:
首先会记录各节名称,类型,地址,偏移量,大小,全体大小,旗标,链接,信息,对齐.
.text:程序的机器代码
.rela.text:一个text节中位置的列表,当连接器把这个目标文件和其他文件组合时,需要修改这些位置.
.data:已经初始化的全局变量和静态c变量
.bss:未初始化的全局变量和静态c变量,以及初始化为0的全局变量和静态c变量
.comment:包含版本控制信息
.note:包含注释信息,有独立的格式
.symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息.
.strtab:一个字符串表,其中包括.symtab和.debug中的符号表,以及节头部中的节名字.
.shstrtab:包含节区的名称
图4-4 节头部表
- 重定位条目
重定位节中有要被修改引用的节偏移,符号索引信息,重定位类型,符号值,符号名称+加数.
如下图所示,本程序中需要被重定位的是.rodata.puts,exit,printf,atoi,sleep,getchar中的模式串
图4-5 重定位条目
- 符号表:
name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字。value是符号的地址。对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址。size是目标的大小(以字节位单位)。type通常要么是数据,要么是函数。符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目。所以这些目标的类型也有所不同。binding字段表示符号是本地的还是全局的。
每个符号都被分配到目标文件的某个节,由section字段表示,该字段也是一个到节头部表的索引。有三个特殊的伪节,它们在节头部表中是没有条目的:ABS代表不该被重定位的符号;UNDEF代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号;COMMON表示还未被分配位置的未初始化的数据目标。对于COMMON符号,value字段给出对齐要求,而size给出最小的大小。注意,只有可重定位目标文件中才有这些伪节,可执行目标文件中没有。
图4-6 符号表
4.4 Hello.o的结果解析
命令行objdump -r hello.o > hellooasm.text 生成反汇编文件hellooasm.text
图4-7 反汇编命令
图4-8 反汇编文件的查看
机器语言是一种指令集的体系。这种指令集,称机器码,是电脑的CPU可直接解读的数据。总的来说机器语言由一串数字码组成,对应着相应的汇编语言。因为机器本身只能读懂这种数字型的指令。机器语言和汇编语言中间是存在某种对应关系的,但是比较复杂,我们可以确定的是一些指令有着固定的机器语言。
通过与hello.s的对比发现两者大体一致,但是有一下几点区别:
- 操作数:hello.o的反汇编文件中的代码的操作数是以16进制表示的,而hello.s中的操作数是以十进制表示的.而且反汇编代码省略了指令结尾的“q”
- 分支转移:hello.o的反汇编代码中,跳转指令后直接计算出了要跳转的精确位置.如:
图4-9 反汇编文件的分支转移
而hello.s中的跳转是利用助记符.L1,.L2来进行的.
- 函数调用:hello.o的反汇编代码中,call指令使用的是相对于main函数的偏移地址,同时在.rel.text节中为其添加了重定位条目,在链接之后会为其确定物理地址.
图4-9 反汇编文件的函数调用
而hello.s中,call指令后面会直接跟函数名称.
4.5 本章小结
本章主要了解了关于汇编的基础流程和作用,一个汇编语言文件是如何通过汇编器翻译成机器可识别的机器语言的,以及对于直接编译hello.c文件得到的汇编代码和反汇编hello.o文件得到的汇编代码的区别。经过汇编阶段,汇编语言代码转化为机器语言,生成的可重定位目标文件为随后的链接阶段做好了准备。
第5章 链接
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 > hello.txt生成文件便于查看hello的elf格式
- ELF头.可以看出hello的ELF头与hello.o的很相似。
但是节头部偏移量增加为13560字节,节头部表中条目的大小增加为27,下表从0到26
图5-2 hello的elf头
可以看到节头部表也和hello.o的很相似
多出来的部分节:
.interp:该段决定了动态链接器在操作系统当中的位置.该段保存了一个字符串,这个字符串就保存了所需动态链接器的位置.
.dynamic:该段保存了动态链接器所需要的基本信息。
.dynsym:该段保存了与动态链接相关的符号,该符号表中记录了动态链接符号在动态符号字符串表中的偏移。
.dynstr:该段是.dynsym段的辅助段.
.rel.dyn:对数据引用的修正,其所修正的位置位于 “.got”以及数据段
.rel.plt:对函数引用的修正,其所修正的位置位于 “.got.plt”。
图5-3 hello的节头部表
2. 符号表:
分别为hello的动态符号表和符号表,比hello.o多了一个动态符号表
存放了在程序中定义和引用的函数和全局变量的信息.
而且函数名被替换为更加详细的内容
图5-4 hello的符号表
3. 重定位信息:
显然,相比与hello.o多出来了一个.rela.dyn节,存放和动态链接相关的信息,是对于数据引用的修正.
此外,程序的函数变成了类似@GLIBC_2.2.5+0的形式,说明其在相关的库中找到了这个函数,是对于函数的修正。
图5-5 hello的重定位条目
5.4 hello的虚拟地址空间
命令行 gdb --run ./hello
图5-6 用gdb打开hello
1.text节:该段地址从text段地址从0x4010f0开始,偏移量是0x10f0,大小为0x1e5,对齐要求为16
图5-7 text节
2.init节:该段代表程序的开始,存放着指令的机器码.可知该段地址从0x401000开始,偏移量为0x1000.
图5-8 init节
3.fini节:该段代表程序的结束.可知该段地址从0x4011c0开始,偏移量为0x11c0.
图5-9fini节
4.data节:由于没有全局变量所以该段为0.
5.5 链接的重定位过程分析
命令行:objdump -d -r hello
图5-10 hello的重定位命令
不同之处:
- 代码起始位置:hello的反汇编代码从0x401000处开始,但是hello.o的反汇编代码从0开始.
图5-11 hello的代码起始位置
2.引入的函数:
Hello的反汇编代码增加了许多函数内容,这些引用都是通过重定位过程添加进来的.而hello.o的反汇编代码除了.text段就是main函数,没有更多的内容了.
图5-12 hello引入的函数
3. 使用虚拟地址:
Hello的反汇编代码在函数跳转时,采用的是虚拟地址,因为hello已经完成了重定位,可以用具体的地址代替,而hello.o的反汇编代码在跳转时使用的位置仅仅时对于main函数的,因为hello.o还没有重定位,所以用0代替.
图5-13 hello使用的虚拟地址
4. 调用.rodata数据
Hello中已经完成了重定位,具体标记出了静态字符串的位置,但是hello.o中用0x0(%rip)代替.
图5-14 hello调用的rodata数据
链接的过程:利用ld命令将各个库中使用所使用道德函数加载到同一文件当中,将地址转化为虚拟内存,然后重定位到这些函数和静态存储区的数据.
重定位的过程:链接器完成符号解析后,将代码中每个符号引用和一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来,此时,链接器就知道了它的输入目标模块中的代码节和数据节的确切大小。重定位首先应进行重定位节和符号定义,在这一步骤中,链接器将所有相同类型的节合并为同一类型的新的聚合节,然后程序将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,由此程序中的每一条指令和全局变量都有唯一的运行时内存地址。其次,进行重定位节中的符号引用,这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
5.6 hello的执行流程
在命令行用edb执行命令
图5-15 edb执行
之后逐个STEP检查,可以看到执行流程如下
- 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
- -libc-2.27.so!_sigsetjmp 0x7fce 8c884b70
- –libc-2.27.so!__sigjmp_save 0x7fce 8c884bd0
- 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
下图是edb调试的一个过程实例
图5-16 edb调式实例
5.7 Hello的动态链接分析
根据ELF格式中.rela.plt的内存位置查看需要动态链接的函数的地址:
图5-17 hello中rela.plt的内存位置
在dl_init调用之前,发现对应区域为空:
图5-18 调用dl_init之前rela.plt的内存位置对应区域
调用之后,发现该区域成功链接,产生了内容:
图5-19 调用dl_init之后rela.plt的内存位置对应区域
在dl_init调用之后,对应的地址被赋予了相应偏移量的值,完成了动态链接.
5.8 本章小结
链接是生成一个可执行文件的最后一步,它将各个模块整合在一起。这种模块化的整合模式使得模块化编程称为可能。链接器将各个目标文件的各个段分割合并,并确定了每个符号的实际意义与地址。静态链接在程序执行前就完成了所有的工作,而动态链接则是在程序运行的时候才进行链接。至此,一个C语言文本真正称为了一个可执行文件
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程的经典定义是一个执行中程序的实例,系统的每个程序都运行在某个进程的上下文。
进程的作用:通过进程,我们会得到一种假象,好像我们的程序是当前唯一运行的程序,我们的程序独占处理器和内存,我们程序的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell的作用:Shell是一个命令解释器,是用户使用Linux的桥梁,它解释由用户输入的命令并且把它们送到内核,提供用户与内核进行交互操作的一种接口,并且允许用户编写由shell命令组成的程序。
处理流程:
用户输入键盘信号,shell应该接受这些键盘输入信号,并对这些信号进行相应处理:命令的执行分为四大步骤 :输入、解析、扩展和执行.
从终端读入输入的命令,将输入字符串切分获得所有的参数;shell对用户输入命令进行解析,判断是否为内置命令,如果是则立即执行;若不是内置命令,则会检查是否为一个可执行文件,如果是,则会fork子进程,启动加载器在当前进程中加载并运行程序;如果不是内置命令且无法找到这个可执行文件,则会显示一条错误信息;如果程序是前台运行程序,则调用等待函数等待前台作业结束;否则将程序转入后台,直接开始下一次用户输入命令.
6.3 Hello的fork进程创建过程
如果我们在shell中输入./hello,shell就会对我们的命令进行解析,如果发现不是内置命令则会认为hello是一个可执行目标文件,就会通过调用加载器来运行它.当shell运行一个程序时,父进程通过fork函数生成这个程序的进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时。子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的PID。
6.4 Hello的execve过程
execve函数带参数列表argv和环境变量列表envp。execve函数若成功则调用一次从不返回。execve函数会在当前进程的上下文中加载并运行我们的可执行目标文件,删除子进程现有的虚拟内存段,创建一组新的段(栈与堆初始化为0),并建立虚拟地址空间和磁盘文件的映射,在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段,之后,加载器跳转到程序的入口:_start函数的地址。_start函数调用系统启动函数,_libc_start_main(该函数定义在libc.so里),之后初始化环境,调用用户层的main函数,处理main函数返回值,并且在需要的时候返回给内核。值得注意是的是,execve函数是覆盖当前进程的地址空间,并没有创建一个新的进程,因的程序仍有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。
6.5 Hello的进程执行
多个流并发地执行的一般现象被称为并发。
一个进程和其他进轮流运行的概念称为多任务。
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
进程调度的过程:
操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务,内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。在程序执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这个决策就被称为调度。该过程可以用三步来描述:1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
用户模式与内核模式的转换:
hello程序与操作系统其他进程通过操作系统的调度,切换上下文,拥有各自的时间片从而实现并发运行。针对我们的hello程序而言,sleep函数的调度就是这样一个过程:
1)hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式;
2)内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时;
3)内核进行上下文切换将当前进程的控制权交给其他进程;
4)当计时完成时,发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态;
5)至此,hello进程就可以继续进行自己的控制逻辑流了。
6.6 hello的异常与信号处理
1.异常:
hello执行过程中可能出现四类异常:中断、陷阱、故障和终止。
(1)中断是异步发生的,是来自外部I/O设备的信号的结果,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断一样。
(2)陷阱是同步发生的,是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
(3)故障是同步发生的,是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
(4)终止是同步发生的,是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。
2.信号处理:
首先是正常状态:
图6-1 正常状态
不停乱按,包括回车:
图6-2 不停乱按,包括回车
如果只是单纯的不停乱按,那么仅仅只会把输入的字符缓存起来,但是如果输入回车,那么就会把回车之前的字符当作输入的命令
- Ctrl-Z
输入Ctrl-Z会发送一个SIGSTP信号给前台进程组的每一个进程,结果是停止前台进程。结果hello程序被停止了.
图6-3 Ctrl-Z
Ctrl-C
输入Ctrl+C会发送一个SIGINT信号给到前台进程组的每个进程,结果是终止前台进程.结果hello程序被终止了.
图6-4 Ctrl-C
Ctrl-z后运行ps
可以看到被停止的进程hello的名称和pid
图6-5 Ctrl-Z后运行ps
Ctrl-z后运行jobs
暂停的程序被正确的显示,而且被标记为已停止.
图6-6Ctrl-Z后运行jobs
Ctrl-z后运行pstree(部分截图)
可以清楚看到各个进程之间的关系,即那个进程是父,哪个进程是子.
图6-7 Ctrl-Z后运行pstree
Ctrl-z后运行fg
fg的功能是使第一个后台作业变为前台,而第一个后台作业是hello,所以输入fg 后hello程序又在前台开始运行,并且是继续刚才的进程,输出剩下的3个字符串
图6-8 Ctrl-Z后运行fg
(10)Ctrl-z后运行kill
先用ps查看hello的pid,然后输入kill -9 10892,接着再次用ps查看,发现hello已经被终止.hello进程被杀死了.
图6-9 Ctrl-Z后运行kill
6.7本章小结
本章介绍了进程的概念以及作用,详细分析了shell,fork,execve以及进程的调度和上下文的切换。最后分析了异常的种类,并且用具体的命令分析了不同情况下信号的处理,对进程和异常的理解又有了深入的了解.
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:又称相对地址,是程序运行由CPU产生的与段相关的偏移地址部分。他是描述一个程序运行段的地址。
物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。他是在前端总线上传输的而且是唯一的。在hello程序中,他就表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。
线性地址:这个和虚拟地址是同一个东西,是经过段机制转化之后用于描述程序分页信息的地址。他是对程序运行区块的一个抽象映射。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在段式存储管理中,将程序的地址空间划分为若干个段(segment),这样每个进程有一个二维的地址空间。在前面所介绍的动态分区分配方式中,系统为整个进程分配一个连续的内存空间。而在段式存储管理系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。
程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。在为某个段分配物理内存时,可以采用首先适配法、下次适配法、最佳适配法等方法。在回收某个段所占用的空间时,要注意将收回的空间与其相邻的空间合并。段式存储管理也需要硬件支持,实现逻辑地址到物理地址的映射。
程序通过分段划分为多个模块,如代码段、数据段、共享段:可以分别编写和编译;可以针对不同类型的段采取不同的保护;可以按段为单位来进行共享,包括通过动态链接进行代码共享。这样做的优点是:可以分别编写和编译源程序的一个文件,并且可以针对不同类型的段采取不同的保护,也可以按段为单位来进行共享。
总的来说,段式存储管理的优点是:没有内碎片,外碎片可以通过内存紧缩来消除;便于实现内存共享。缺点与页式存储管理的缺点相同,进程必须全部装入内存。
图7-1 逻辑到线性变换
7.3 Hello的线性地址到物理地址的变换-页式管理
由于我们使用了分页技术,因而需要我们将hello的线性地址转换为物理地址。首先,我们给出页的定义:
页,即N个连续字节的数组,具体可分为虚拟页和物理页,如下图:
图7-2 VM系统如何将主存作为缓存使用
如果不考虑多级页表等更加精细的机制,我们的地址映射将是这样进行的:线性地址,即虚拟地址,被分成了两部分:
线性地址=VPN(虚拟页号)+VPO(虚拟页偏移量)。
虚拟页号是一个索引,在当前进程的CR3寄存器指向当前的页表里面寻找虚拟页,并把里面存的物理页号PPN与物理页偏移量PPO返回,即:
物理地址=PPN(物理页号)+PPO(物理页偏移量)。
其中,页命中指虚拟内存中的一个字存在于物理内存中,缺页指引用虚拟内存中的字,不在物理内存中。
7.4 TLB与四级页表支持下的VA到PA的变换
首先在TLB中查找PTE,若能直接找到则直接得到对应的PPN,具体的操作是将VPN看作TLBI和TLBT,前者是组号,后者是标记,根据TLBI去对应的组找,如果TLBT能够对应的话,则能够直接得到PTE,进而得到PPN。
图7-3 地址转换
其中若是在TLB中找不到对应的条目,则应去多级页表中查找,VPN被分为了四块。有一个叫做CR3的寄存器包含L1页表的物理地址,VPN1提供到了一个L1
PET的偏移量,这个PTE包含L2页表的基地址,VPN2提供到一个L2
PTE的偏移量。依次类推,最终找到页表中的PTE,得到PPN。
而VPO和PPO相等,最终的PA等于PPN+PPO。
7.5 三级Cache支持下的物理内存访问
对于一个虚拟地址请求,首先将去TLB寻找,看是否已经在TLB中缓存。如果命中的话就直接MMU获取,没有命中的话就先在结合多级页表,得到物理地址,去cache中找,到了L1里面以后,寻找物理地址又要检测是否命中,不命中则紧接着寻找下一级cache L2,接着L3。这里就是使用到CPU的高速缓存机制了,一级一级往下找,直到找到对应的内容。
7.6 hello进程fork时的内存映射
mm_struct(内存描述符):描述了一个进程的整个虚拟内存空间
vm_area_struct(区域结构描述符):描述了进程的虚拟内存空间的一个区间
在用fork创建虚拟内存的时候,要经历以下步骤:
创建当前进程的mm_struct,vm_area_struct和页表的原样副本
两个进程的每个页面都标记为只读页面
两个进程的每个vm_area_struct都标记为私有,这样就只能在写入时复制。
图7-4 fork时的内存映射
7.7 hello进程execve时的内存映射
加载hello并执行需要以下几个步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构(即mmap指向的vm_area_structs)。
2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域时请求二进制0的,映射到匿名文件,其大小包括在hello中。栈和堆区域也是请求二进制0的,初始长度为0.
3.映射共享区域。如果hello程序域共享对象链接,比如C标准库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
首先,处理器将虚拟地址发送给MMU,MMU利用虚拟地址对应的虚拟页号生成页表项(PTE)地址,并从页表中找到对应的PTE。PTE中的有效位为0,MMU触发缺页异常。然后,缺页处理程序选择物理内存中的牺牲页(若页面被修改,则换出到磁盘)。缺页处理程序调入新的页面到内存,并更新PTE。最后,缺页处理程序返回到原来进程,再次执行导致缺页的指令
图7-5 缺页异常及其处理
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,指向堆的顶部。
图7-6 堆
分配器有两种风格,但是这两种风格都要求应用显示的分配块。
其中显示分配器要求显示释放任何已分配的块,如malloc、new等。
隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。因此也叫垃圾收集器。
显示分配器有以下几点要求:能够处理任意请求序列;立即相应请求;只是用堆;对齐开;不修改已分配的块。
在性能上有两点追求:
1.最大化吞吐率;
2.最大化内存利用率;
但是这两点通常是对立的。
具体的技术有以下:隐式空闲列表和显式空闲链表
图7-7 隐式空闲列表和显式空闲链表
7.10本章小结
本章是理解计算机系统存储的重中之重,从内存的分页式管理,到三级cache的高层内存管理,再到一个程序内部的堆的管理。其中的思想十分复杂,却又十分精巧。
我们主要介绍了储存器的地址空间,讲述了虚拟地址、物理地址、线性地址、逻辑地址的概念,还有进程fork和execve时的内存映射的内容。描述了系统如何应对那些缺页异常,最后描述了malloc的内存分配管理机制,操作系统为了稳定的运行,不辞劳苦,精妙的安排了内存的空间,既要分配内存,又要回收内存。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
文件是Linux管理的基本思想,所有的IO设备都被抽象为文件,所有的输入输出操作都作为对文件的操作。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得输入和输出都能以一种统一且一致的方式的来执行。
8.2 简述Unix IO接口及其函数
Unix I/O接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为K 。
4.读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号”。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k 。
5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数:
1. int open(char* filename,int flags,mode_t mode),进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
2. int close(fd),fd是需要关闭的文件的描述符,close返回操作结果。
3. ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
4. ssize_t write(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
Printf的运行过程:
从vsprintf生成显示信息,显示信息传送到write系统函数,write函数再陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序。从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
printf需要做的事情是:接受一个fmt的格式,然后将匹配到的参数按照fmt格式输出。
图7-8 print格式
上面是printf的代码,我们可以发现,他调用了两个外部函数,一个是vsprintf,还有一个是write
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
这是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从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符显示到屏幕。如果用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章简要总结I/O的有关知识,通过对外部设备的模型化实现简单、贯通的读写操作,将所有的IO设备看作文件,通过简单的几个系统函数的组合就完成了各种的读写操作。同时还提供了一种树结构的文件管理模式,并且把目录也抽象为了一种文件。
需要我们重点在编程中掌握read、write、lseek、open、close等基本函数,深入理解内部机制,做到安全的对文件进行读写。
结论
- hello程序历程总结
hello程序虽然是一个简短的C语言程序,但它的产生、执行、终止和回收离不开计算机系统各方面的协同工作,具体过程如下:
- hello.c源代码文件通过C语言预处理器的预处理,得到了调整、展开后的ASCII文本文件hello.i;
- hello.i经过编译器的编译得到汇编代码文件hello.s;
- hello.s经过汇编器的汇编得到可重定向目标文件hello.o;
- hello.o经过链接器的链接过程成为可执行目标文件hello;
- 用户在shell-bash中键入执行hello程序的命令后,shell-bash解释用户的命令,找到hello可执行目标文件并为其执行fork创建新进程;
- fork得到的新进程通过调用execve完成在其上下文中对hello程序的加载,hello开始执行;
- hello作为一个进程运行,接受内核的进程调度(调用sleep后内核进行上下文切换,调度其他进程执行);
- hello执行的过程中,可能发生缺页异常等故障、系统调用等陷阱以及接收到各种信号,这些都需要操作系统与硬件设备的协同工作进行处理;
- hello执行的过程中会访问其虚拟空间内的指令和数据,需要借助各种硬件、软件机制来快速、高效完成;
- hello运行时要调用printf、getchar等函数,这些函数的实现与Linux系统IO设备管理、Unix IO接口等息息相关;
- hello程序运行结束后,父进程shell-bash会进行回收,内核也会清除在内存中为其创建的各种数据结构和信息。
- 个人感悟
- 计算机系统的设计和实现大量体现了抽象的思想:文件是对I/O设备的抽象,虚拟内存是对主存和磁盘设备的抽象,进程是对处理器、主存和I/O设备的抽象,进程是操作系统对一个正在运行的程序的抽象等等;
- 计算机系统课程内容虽然繁多,但逻辑结构清晰、层次分明。完成本论文让我体会到:一个小小的hello程序就能展现计算机系统的方方面面;
- CSAPP这本书及计算机系统课程引领我从程序员的角度第一次系统地、全面地认识了现代操作系统的各种机制、设计和运行原理。我会在未来继续深入学习相关知识并在今后的具体实践中不断尝试使用和创新。
- 创新理念:
在追踪函数的运行时利用gdb辅助,避免了edb编码不清的问题.
附件
hello.c: hello的源文件.
hello.i:经过预处理后产生的文件.
hello.s:经过编译后产生的文件,包含一个汇编语言程序.
hello.o:经过汇编后产生的文件,是一个二进制可重定位文件.
hello:经过链接后产生的可执行目标文件.
hello.elf.text:hello.产生的elf文件
hellooasm..text:hello.反汇编后产生的文件
hello.text:hello产生的elf文件
参考文献
[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.