本文以最简单的hello程序作为例子,阐述了hello程序在Linux系统的运行步骤,讲解了从hello.c到最后hello程序运行结束的过程:有预处理到编译;由汇编到链接;由进程到存储再到I/O。作者在一学期计算机系统的学习与实验的基础上,结合课本知识,根据自己的理解和网上的查阅的资料,对hello看似简单实则伟大且内蕴十足的一生进行了整理,并以此文作为报告的形式展现在了大家的面前。
关键词:hello程序;预处理;深入理解计算机系统;编译;汇编;链接;进程;存储;I/O;虚拟内存
目录
1.1 Hello简介
图1.1.1 hello程序基本执行过程
P2P:意为 From Program to Process。hello程序经过cpp的预处理,ccl的编译,as的汇编,ld的链接的种种洗礼,从一个“小白”.c文件,变成了一个可以直接执行的“强大的”可执行文件,在shell中输入hello程序的启动命令后,将会为其fork出一个子进程,然后便完成了hello的进化:从程序变成了进程。
020:意为From Zreo-O to Zero-O,shell为此子进程execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。
1.2 环境与工具
软件环境:
Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
硬件环境:
X64 CPU;2GHz;2G RAM;256GHD Disk 以上
开发与调试工具:
gcc,vim,edb,readelf,HexEdit,as,ld,edb,readelf,codeblocks
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello: 链接之后的可执行目标文件
hello.c: 源文件
hello.elf: hello的ELF格式
hello.i: 预处理产生的文本文件
hello.o: 汇编产生的可重定位目标文件
hello.objdmp: hello的反汇编代码
hello.s: 编译产生的汇编文件
hello.txt: hello.o的ELF格式
1.4 本章小结
本章详细阐述了hello 的P2P 和 020 的过程,主要介绍了我进行本次实验所用的环境,产生的中间结果文件等,大致介绍了hello从产生到变成可执行文件的过程。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理也称为预编译,它为编译做预备工作,主要进行代码文本的替换工作,用于处理#开头的指令,其中预处理器产生编译器的输出。此处预处理是在编译之前,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第6行的#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。
作用:将源文件中用#include形式声明的文件复制到新的程序中。
删除所有注释。比如:由“//”后的内容或“/*”与“*/”之间的内容。
删除“#define”,用实际值替换#define定义的字符串。
2.2在Ubuntu下预处理的命令
预处理命令:gcc -E hello.c -o hello.i
截图示例:
图2.2.1 对hello程序进行预处理
2.3 Hello的预处理结果解析
打开.i文件与.c文件对照发现,文件得到了扩充,从原来的寥寥几行代码变成了一个含有3000多行的文本文件,而代码在最后出现。这是由于,原来文件中的stdio.h , unistd.h , stdlib.h等都进行了展开,头文件中的内容也都包含进了.i文件中(经过递归替换逐步包含)。
综上,经过预处理之后,hello.c文件转化生成了一个只有常量如:数字、字符和变量的定义的输出文件.i(此时的.i文件中没有宏定义、条件编译指令等)
图2.3.1 hello.i与hello.c进行对比
2.4 本章小结
预处理是将源程序经过预处理器转换修改的过程。它是编译之前的操作,根据以字符#开头的命令,修改原始的C程序,由hello.c生成hello.i(还是可读的C语言风格的文本文件)。
第3章 编译
3.1 编译的概念与作用
概念:编译是编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。作用:通过词法分析,语法分析,目标代码的生成进行词法分析和语法分析,分析过程中发现有语法错误,给出提示信息。
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
截图示例:
图3.2.1 对hello程序进行编译
3.3 Hello的编译结果解析
打开.s文件,我们看到如下图所示的示例。
图3.3.1 hello.s的部分代码
其中包含了我们的汇编语言程序指令:
.file:声明源文件
.text:代码节
.section:指示把代码划分成若干个段
.rodata:只读代码段
.align:数据或者指令的地址对其方式
.string:声明一个字符串(.LC0,.LC1)
.global:声明全局变量(main)
.type:声明一个符号是数据类型还是函数类型
图3.3.2 hello.s中的main部分
以下是对编译器是怎么处理C语言的各个数据类型以及各类操作的说明:
3.3.1数据:
- 常量:即各种立即数,宏等,立即数直接体现在汇编代码中
- 变量:
- 局部变量:main函数中定义了一个int型的变量:int i,并在循环中进行了使用,对于局部变量,编译器会将它放在寄存器或栈中,为了确定i的位置,我们在.s文件中找到它的汇编代码:
在函数当中,我们可以读到:“addl $1,-4(%rbp)”(这是循环时,每循环一次,i加1),也就是说,i应该是在-4(%rbp)的位置,根据这一条线索,我们可以找到如下的汇编代码:
图3.3.3 i的执行反汇编代码
,可知,i是在栈里的。
2.全局变量:存放在静态存储区中,程序开始执行时给全局变量分配存储空间,程序执行完毕就释放。
- 字符串:
hello.c中,有两个地方涉及到了字符串的输出,如图所示,这两个字符串作为了printf函数的参数:
图3.3.4 hello.c程序中printf对字符串的使用
如果我们在./hello 后没有输入两个字符串,那程序就会打印
“用法:Hello学号 姓名 描述!”来提示输入格式:
图3.3.5 printf第一处使用字符串
如果我们在./hello 后再输入两个字符串(形如“./hello xx xx”)就会形成三个参数,就会正常输出我们输入的字符串:
图3.3.6 printf第二处使用字符串
这两个字符串,在编译的时候,编译器把它们放在了.rodata节(只读段)中:
图3.3.7 字符串存放位置
- main函数:
参数 argc 作为用户传给main的参数。也是被放到了堆栈中。
3.3.2赋值操作:
在循环开始的时候,我们将i赋值为0:,这是通过movl指令完成的:。
程序中的赋值操作主要有:i=0这条赋值操作在汇编代码主要使用mov指 令来实现,而根据数据的类型又有好几种不一样的后缀
movb:一个字节
movw:两个字节
movl:四个字节
movq:八个字节
3.3.3类型转换:
类型转换是指将数据由一种类型变换为另一种类型。在编译器自动赋值时,会发生隐式转换,但在代码中,也可以用一些写法强制要求进行显式转换。
hello.c中涉及的类型转换是:atoi(argv[3]),将字符串类型转换为整数类型其他的类型转换还有int、float、double、short、char之间的转换
3.3.4算术操作:
hello.c中的算术操作存在于for循环中:for(i = 0; i < 8;i++),每循环一次都要对i进行加1操作:addl $1,-4(%rbp)。
图3.3.8 各种算数操作的指令和效果
3.3.5逻辑/位操作:
位逻辑运算符和位移运算符,按位对每个二进制位进行运算,操作数和运算结果都是整数型,且只能对byte , short , char , int , long进行运算
图3.3.9 逻辑运算符/位运算符的基本信息等
3.3.6关系操作:
hello.c中有两个地方使用了关系操作:
. 1、if(argc!=4):
在最开始的时候,判断argc的大小,因为argc是main函数的第一个参数,所以,他应该存放在%edi中。在.s文件中我们可以找到相对应的汇编语言代码:
cmpl $4, -20(%rbp)
je .L2 :
在这里,它判断argc是否为4,从而来决定程序接下来应该如何执行(其中4是立即数,-20(%rbp)是argc)。若相等,就跳转到.L2。
2. for(i=0;i<8;i++):
在for循环中,每次都会比较i的大小来判断循环是否结束,在.s文件中可以找到相对应的汇编代码:,这里是比较i和7的大小,如果小于等于就跳转,所以循环的条件是:i<=7(与i<8的效果是等价的)。
3.3.7数组/指针/结构操作:
在hello程序中,我们向main函数传入了argv[]作为第二个参数,argv[]是一个指针数组:
图3.3.10 main函数中对argv[]的使用
在汇编代码.s的文件中,我们可以找到相关的汇编代码:
(黄色标注的位置)
图3.3.11 在.s文件中的代码
因为argv[]是指针类型,所以它的大小应该是8字节的所以用%rsi传给了%rbp,其中argv[1]在8(%rbp)的位置,argv[2]在16(%rbp)的位置,argv[3]在24(%rbp)的位置。
3.3.8控制转移:
1. if(argc!=4):
根据参数argc的值决定函数接下来的程序应该怎样进行:如果argc等于4,那么我们的程序就往下顺序执行;如果argc不等于4,那么我们的程序就会跳转到.L2:
2. for(i=8;i<8;i++):
在汇编代码中,我们先无条件跳转到位于循环体.L4之后的比较部位,比 较i与7的大小,如果i小于等于7,则跳转到.L4进行循环,否则的话顺序往 下进行其他操作:
图3.3.12 循环使用部分的汇编代码
3.3.9函数操作:
我们会从以下几个方面分析分析函数的调用
1.参数传递:
当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。x86-64中, 大部分过程间的数据传送是通过寄存器实现的。例如,我们已经看到无数的函数示例,参数在寄存器%rdi、%rsi和其他寄存器中传递。当过程P调用过程Q时,P的代码必须首先把参数复制到适当的寄存器中。类似地,当Q返回到P时, P的代码可以访问寄存器%rax 中的返回值。
2.函数调用:
调用函数时,会将返回地址压入栈中,并且将rip的值指向所调用函数的地址,函数运行时,将进行某些寄存器的原始值保存,利用传递进来的参数进行操作,并且将返回结果放在rax寄存器中,等函数执行完之后调用ret弹出原来的rip并且将栈帧结构恢复。
3.函数返回:
函数返回时,如果有返回值,则先将返回值存在%rax中,再用ret等操作返回。
我们将对main函数,exit函数,puts函数,printf函数,atoi函数,sleep函数,getchar函数以上几点进行分析:
1).main函数:
图3.3.13 main函数
main函数是在主程序里面定义的一个全局符号,它被系统函数所调用, 编译器将main函数在.text节中声明为全局的函数。
图3.3.14 main函数在.text的节中
参数传递:由于main是系统调用的,所以它的参数之前就在%rsi和%rdi中了,而当在shell中执行可执行文件hello时,execve函数将argc和argv[]作为参数调用加载器传递给main函数。
函数调用:main是系统调用的。
函数返回:通过,将0压入到eax中,然后调用了leave平衡栈帧,返回退出。
图3.3.15 main函数汇编
2).exit函数
exit(1):
参数传递:将1传给了%edi,完成参数传递。
函数调用:通过call exit@PLT函数,进行函数调用。
call exit@PLT
函数返回:从exit返回。
3).puts函数
printf("用法:Hello 学号 姓名 秒数!\n");
参数传递:puts函数只需要输出一个字符串常量,也就是一个数组,它将.LC0位置的字符串首地址复制过来赋值给%rdi作为参数。
leaq .LC0(%rip),%rdi
函数调用:通过call puts@PLT函数,进行函数调用。
call puts@PLT
函数返回:从puts中返回。
4). printf函数
参数传递:printf函数是格式化输出,且具有多个参数传递,当参数在6个以内时,使用寄存器传参,超过7个则用栈来传递。在本次的函数中,只有3个参数,所以用%rdi,%rsi,%rdx进行传递。参数为字符串首地址,但后两个存在字符数组里面,所以在寻址时,通过内存寻址的方法:第一步取出刚刚在-32(%rbp)存放的argv首地址,然后将argv[1],argv[2]的地址搞出来,再通过间接寻址将其中存放的内容,即字符串的地址取出。
图3.3.16 printf函数汇编代码
函数调用:通过call puts@PLT函数,进行函数调用。
函数返回:从printf中返回。
5). atoi函数
atoi(argv[3])
参数传递:将argv[3]通过%rdi传递给atoi函数。
movq %rax , %rdi
函数调用:通过call atoi@PLT函数,进行函数调用。
函数返回:从atoi中返回。
6).sleep函数
sleep(atoi(argv[3])
参数传递:将atoi的返回值%eax通过%rdi传递给sleep函数。
movl %eax , %edi
函数调用:通过call sleep@PLT函数,进行函数调用。
7).getchar函数
getchar();
参数传递:无参数传递。
函数调用:通过调用getchar@PLT函数,进行函数调用。
函数返回:从getchar中返回。
3.4 本章小结
本章主要阐述了编译阶段中编译器如何处理各种数据和操作,通过编译使程序从.i文件到.s文件,也就是函数的c代码变为了类型和操作所对应的的汇编代码,编译器分别从c语言的数据,赋值语句,类型转换,算术操作,逻辑/位操作,关系操作,指针操作,控制转移与函数操作这几个关键点布局:首先是对各个全局变量进行的声明,包括main函数。对于hello.c中定义的函数,则在声明之后跟上它内部相应的代码。编译器此处做的各种工作一个是为了接下来汇编器将.s文件中的汇编语言转化为机器指令,通过理解了这些编译器编译的机制,我们可以很容易的将汇编语言翻译成c语言。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编是汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它包含的17个字节是函数main 的指令编码。如果我们在文本编辑器中打开hello.o文件,将看到一堆乱码。
作用:将编译过的.s文件中的汇编语言指令转化成机器语言指令并且装入到.o可重定向文件。生成每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,根据汇编指令和机器指令的对照表一一翻译即可。
4.2 在Ubuntu下汇编的命令
命令:gcc hello.s -c -o hello.o
截图示例:
图4.2.1 对hello程序进行汇编
4.3 可重定位目标elf格式
命令:readelf -a hello.o
ELF头:
ELF头以一个16进制字节的序列开始,这个序列描述了生成该文件的系统字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(可重定位、可执行或者可共享的)、机器类型(如x86-64)、节头部表的文件便宜,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中的每个节都有一个固定大小的条目。
图4.3.1 ELF Header
节头部表:
包含了文件中出现的各个节的语义,包括节 的类型、位置和大小等信息。
图4.3.2 Section Headers
重定位项目:
当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
图4.3.3 Relocation section
符号表:
一种用于语言翻译器(例如编译器和解释器)中的数据结构。在符 号表 中,程序源代码中的每个标识符都和它的声明或使用信息绑定在一起,比 如其数据类型、作用域以及内存地址。存放程序中定义和引用的函数和全局变 量的信息。name是符号名称,对于可重定位目标模块,value是符号相对于目 标节的起始位置偏移,对于可执行目标文件,该值是一个绝对运行的地址。Size 是目标的大小,type要么是数据要么是函数。Bind字段表明符号是本地的还 是全局的。
图4.3.4 Symbol table
图4.3.5 典型的ELF可重定位目标文件
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o
与 hello.s的对照:
图4.4.1 反汇编代码
图4.4.2 hello.s
通过对照发现,两者大体上区别不大,主要区别有以下几点:
在.s文件中的 ,在.o文件中已经变成了 ,我们可以看出:10进制数32已经转换为16进制的0x20。
2. .o文件中所有的跳转都是采用的相对寻址
举个例子,.s文件中的,在.o文件中已经变成了,.L3已经由地址<main+0x7c>来表示了。
3.调用函数时,调用方式有所不同
在调用函数时,从直接call“函数名“,变成了计算出函数与下一条指令的相对位置,通过相对寻址,然后再后边添加重定位条目进行调用。比如:在.o文件中变成了
4.指令有所不同
比如:有的指令在.o文件中少了q:.s文件中的movq( )在.o文件中变成了mov();
5..o文件每一条汇编语言前都有对应的机器码。
比如:
图4.4.3 main函数的反汇编代码和机器码
4.5 本章小结
本章对hello.s进行了汇编,生成了hello.o可重定位目标文件,并且分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,比较了hello.s和hello.o反汇编代码的不同之处,分析了从汇编语言到机器语言的一一映射关系,而且经过编译后,编译过的.s文件中的汇编语言指令转化成机器语言指令并且装入到.o可重定向文件,生成每一个汇编语句几乎都对应一条机器指令。
第5章 链接
5.1 链接的概念与作用
概念:链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可执行于编译(compile time)时,也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在源代码被加载器(loader)加载到内存并执行时;甚至执行于运行时(run time),也就是由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
作用:链接使得分离编译成为可能,即可以将一个大项目分解为较小的、更好管理的模块,可以单独对其进行修改和变异,最后再将其链接到一起
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 对hello进行链接
图5.2.2 对hello程序进行反汇编并输出成文本文件格式
5.3 可执行目标文件hello的格式
使用命令直接查看所有有关于elf的信息:
图5.3.1 查看hello的elf信息
- ELF Header:
图5.3.1 ELF Header
- 节头部表Section Headers:Section Headers 对 hello中所有的节信息进行了声明,其中包括大小 Size 以及在程序中的偏移量 Offset,因此根据 Section Headers 中的信息我们就可以用 HexEdit 定位各个节所占的区间(起始位置,大小)。其中 Address 是程序被载入到虚拟地址的起始地址。
图5.3.2 Section Headers
- 重定位节.rela.text:
图5.3.3 重定位节
- 字符表:
图5.3.4 字符表
- 版本符号节:
图5.3.5 版本符号节
- 版本需求节
图5.3.6 版本需求节
- 段节:
图5.3.7 段节
- 动态偏移表:
图5.3.8 动态偏移表
- 程序头:
图5.3.9 程序头表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
使用edb打开hello程序,在Data Dump中看到hello的虚拟地址空间:
图5.4.1 edb Data Dump
则由图中得.txt始于0x4010f0处,
与程序头对比:
图5.4.2 程序头表
再与5.3对照得出:
PHDR包含程序头表本身
INTERP:只包含了一个section,在这个节中,包含了动态链接过程中所使用的解释器路径和名称。
LOAD两个:第一个是代码段,第二个是数据段。在程序运行时需要映射到虚拟空间地址。
DYNAMIC:保存了由动态链接器使用的信息。
NOTE: 保存了辅助信息。
GNU_STACK:堆栈段。
GNU_RELRO:在重定位之后哪些内存区域需要设置只读。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
执行objdump -d -r hello命令之后:
图5.5.1 查看hello反汇编代码
图5.5.2 承接5.5.1的剩余部分
图5.5.3 承接5.5.2的剩余部分
图5.5.4 承接5.5.3的剩余部分
此时,将页面与.o文件进行对比,我们发现不同在于:
- hello中不只有main函数原来的代码组成,在函数中调用的函数的汇编代码也包含其中。
- hello增加了新的节:
图5.5.5 节名称与描述表
5.6 hello的执行流程
0000000000401000 <_init>:
0000000000401020 <.plt>:
0000000000401090 <puts@plt>:
00000000004010a0 <printf@plt>:
00000000004010b0 <getchar@plt>:
00000000004010c0 <atoi@plt>:
00000000004010d0 <exit@plt>:
00000000004010e0 <sleep@plt>:
00000000004010f0 <_start>:
0000000000401120 <_dl_relocate_static_pie>:
0000000000401125 <main>:
00000000004011c0 <__libc_csu_init>:
0000000000401230 <__libc_csu_fini>:
0000000000401238 <_fini>:
图5.6.1 edb 查看各函数的内存地址
5.7 Hello的动态链接分析
先在elf中找到GOT表的地址:
之后,我们可以在edb中查看:
在do_init之前:
图5.7.1 原本的表地址
do_init之后:
图5.7.2 初始化之后的表
看之前找到的地址,已经由全为0变成了相应的偏移量。
5.8 本章小结
本章我们介绍了连接的作用和概念,在虚拟机里将hello.o与其他文件链接生成了hello的可执行程序并查看了elf文件,并且详细阐述了hello.o是怎么链接成为一个可执行目标文件的过程,详细介绍了hello.o的ELF格式和各个节的含义,并且分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用:进程提供给应用程序的关键抽象如下:
一个独立的逻辑控制流。它提供一个假象,好像我们的程序独占地使用处理器。
一个私有的地址空间。它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell是一个应用程序,他在操作系统中提供了一个用户与系统内核进行交互的界面。在计算机科学中,Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(命令解析器)。它类似于DOS下的command.com和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。同时它又是一种程序设计语言。作为命令语言,它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令;作为程序设计语言,它定义了各种变量和参数,并提供了许多在高级语言中才具有的控制结构,包括循环和分支。
处理流程:
1.在虚拟的shell界面上出现命令提示符($或#) ;
2.获取用户指令:获取用户在命令提示符后面输入的命令及其参数,并注意命
令输入的最大长度;
3.解析指令:对用户输入的命令进行解析,解析出命令名和参数;如果是内置命令则立即执行,否则调用相应的程序为其分配子程序并进行
4.寻找命令文件:每个命令的执行都必须依靠对应的可执行文件,这些文件的存放路径存放在用户的PATH环境变量里;
5.执行命令:可通过fork系统调用创建一 个进程来完成执行命令的任务,具体的命令执行用execv函数。
6.等待并回收子程序。
6.3 Hello的fork进程创建过程
1.linux系统下的终端中始终运行着一个Shell来执行用户输入的操作,作为用户与系统之间的媒介。在终端中输入./hello 1183710105 陈文韬 1。
2.shell分析输入的命令,发现并不是一个内置命令,所以调用相应的程序解释执行hello程序,
3. 使用fork创建子进程,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。父进程和子进程是并发运行的独立进程,内核能够以任何方式交替执行它们的逻辑控制流中的指令。
图6.3.1 进程执行过程
6.4 Hello的execve过程
图6.4.1 execve的函数实现
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve 调用一次并从不返回。
参数列表是用数据结构表示的。argv 变量指向一个以null 结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0] 是可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的。envp变量指向一个以null结尾的指针数组,其中每个指针指向-一个环境变量字符串,每个串都是形如'name=value” 的名字-值对。
图6.4.2 envp列表
图6.4.3 argv列表
在execve加载了filename之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型:
图6.4.4 main函数形式定义
总结起来就是:先删除已存在的用户区域,再映射私有区域和共享区域,最后设置程序计数器(PC)
图6.4.5 函数的虚拟内存状态
6.5 Hello的进程执行
图6.5.1 进程切换模式
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。上下文切换机制建立在较低层异常机制之上的。
内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
hello一开始运行在用户模式,当调用sleep时进入内核模式,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。
整个程序的执行效果:
图6.5.2 程序示例执行效果
6.6 hello的异常与信号处理
异常可以分为以下几类:
图6.6.1 异常种类
他们的处理方式如下:
中断:
图6.6.2 中断
陷阱:
图6.6.3 陷阱
故障:
图6.6.4 故障
终止:
图6.6.5 终止
hello程序出现的异常可能有:
中断:在hello程序执行的过程中可能会出现外部I/O设备引起的异常。
陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep函数的时候会出现这个异常。
故障:在执行hello程序的时候,可能会发生缺页故障。
终止:终止时不可恢复的错误,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。
在发生异常时会发出信号,比如缺页故障会导致OS发生SIGSEGV信号给用户进程,而用户进程以段错误退出。常见信号种类如下表所示。
图6.6.6 常见信号种类
6.6.1正常执行
当不输入如何参数的时候:
图6.6.7 不输入参数时的运行结果
输出之前设定好的输入格式提示;
当符合输入标准时,每隔相应输入的秒数(我输入的是1秒),就会打印输入的学号和字符串(例如:120L020410 徐靖奇 ),最后用户输入一个字符串,之后程序结束。
图6.6.8 输入字符串作为参数后的运行效果
6.6.2乱序输入
最开始,按规定的格式输入./hello 120L020410 徐靖奇 1,在hello执行的时候,在键盘上随意输入,输入的字符串会打印出来,但hello程序还是正常进行,输出Hello 120L020410 徐靖奇。当运行结束后,发现之前回车间隔之间的输入都会作为shell的命令读入,应该是被放到了缓冲区中。
图6.6.9 进行中乱序输入的执行结果
6.6.3输入^Z
使用ctrl+z之后,将会发送一个SIGTSTP信号给shell,然后shell将转发给当前执行的前台进程组,使hello进程挂起。
图6.6.10 输入^Z后的运行结果
- 输入ps
图6.6.11 输入ps的运行结果
查看当前进程,发现hello还在当前进程中。
- 输入jobs
图6.6.12 输入jobs的运行效果
- 输入pstree
图6.6.13 用pstree查看运行树
- 输入fg
图6.6.14 输入fg 后的运行效果
输入ctrl-z后,hello的进程停止,但是没有消失,这时如果输入fg指令,hello会接着上次停止的地方继续进行。
- 执行kill命令
图6.6.15 kill命令将hello的进程终止
6.6.4输入^C
使用^C的命令,将会发送一个SIGINT信号给shell,然后shell将转发给当前执行的前台进程组,使hello进程被终止,直接退出。
图6.6.16 输入^C后的运行结果
6.7本章小结
本章从进程的角度,对hello进行了分析,从shell调用fork函数生成子程序,再用execve进行加载,最后在hello程序执行过程中,对回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps,jobs,pstree,fg,kill 等命令的处理,让我们对进程有了更好的理解。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址(Logical Address):是指由程式产生的和段相关的偏移地址部分。例如,你在进行C语言指针编程中,能读取指针变量本身值(&操作),实际上这个值就是逻辑地址,他是相对于你当前进程数据段的地址,不和绝对物理地址相干。只有在Intel实模式下,逻辑地址才和物理地址相等(因为实模式没有分段或分页机制,Cpu不进行自动地址转换);逻辑也就是在Intel保护模式下程式执行代码段限长内的偏移地址(假定代码段、数据段如果完全相同)。应用程式员仅需和逻辑地址打交道,而分段和分页机制对你来说是完全透明的,仅由系统编程人员涉及。应用程式员虽然自己能直接操作内存,那也只能在操作系统给你分配的内存段操作。
线性地址(Linear Address):是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386的线性地址空间容量为4G(2的32次方即32根地址总线寻址)。
虚拟地址(vitual address):实际上就是这里的线性地址。
图7.1.1 地址之间的关系
物理地址(Physical Address):是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
图7.1.2 线性地址到物理地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
图7.2.1 逻辑地址的组成
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
图7.2.2 逻辑地址的组成
最后两位涉及权限检查,这里不予以论述。 索引号,是“段描述符(segment descriptor)”,具体地址描述了一个段。这样,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,由8个字节组成,如下图:
图7.2.3 具体的段描述符
图示比较复杂,可以利用一个数据结构来定义它,不过,在此只关心一样,就是Base字段,它描述了一个段的开始位置的线性地址。Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。什么时候该用GDT,什么时候该用LDT是由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。再看这张图比起来要直观些:
图7.2.4 逻辑地址到线性地址
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。 还是挺简单的,对于软件来讲,原则上就需要把硬件转换所需的信息准备好,就可以让硬件来完成这个转换了。
7.3 Hello的线性地址到物理地址的变换-页式管理
Linux将虚拟内存组织成一些区域(也叫做段)的集合。一个区域(area)就是已经存在着的(已分配的)虛拟内存的连续片(chunk),这些页是以某种方式相关联的。例如,代码段、数据段、堆、共享库段,以及用户栈都是不同的区域。每个存在的虚拟页面都保存在某个区域中,而不属于某个区域的虚拟页是不存在的,并且不能被进程引用。
区域的概念很重要,因为它允许虛拟地址空间有间隙。内核不用记录那些不存在的虚拟页,而这样的页也不占用内存、磁盘或者内核本身中的任何额外资源。
图7.3.1 页式管理
而物理内存被划分为一小块一小块,每块被称为帧(Frame)。分配内存时,帧是分配时的最小单位,最少也要给一帧。在虚拟内存中,与帧对应的概念就是页(Page)。
线性地址的表示方式是:前部分是虚拟页号后部分是虚拟页偏移。
图7.3.2 虚拟地址的表示
CPU通过将逻辑地址转换为虚拟地址来访问主存,这个虚拟地址在访问主存前必须先转换成适当的物理地址。CPU芯片上叫做内存管理单元(MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址。然后CPU会通过这个物理地址来访问物理内存。
页表就是一个页表条目(PTE)数组,虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个PTE。PTE是由一个有效位和一个n个字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。
MMU利用虚拟页号(VPN)来在虚拟页表中选择合适的PTE,当找到合适的PTE之后,PTE中的物理页号(PPN)和虚拟页偏移量(VPO)就会组合形成物理地址。其中VPO与PPO相同,因为虚拟页大小和物理页大小相同,所需要的偏移量位数也就相同。此时,物理地址就通过物理页号先找到对应的物理页,然后再根据物理页偏移找到具体的字节:
1.如果有效位是 0+NULL 则代表没有在虚拟内存空间中分配该内存;
2.如果是有效位 0+非 NULL,则代表在虚拟内存空间中分配了但是没 有被缓存到物理内存中;
3.如果有效位是 1 则代表该内存已经缓存在了物理内存中,可以得到其 物理页号 PPN,与虚拟页偏移量共同构成物理地址 PA。
图7.3.3 使用页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
图7.4.1 VA与PA的转换
整个过程从CPU出发。CPU内部将逻辑地址转化为虚拟地址以后,传送给MMU进行地址翻译,MMU首先将VPN取出,构建TLB的标记位和组索引,然后在翻译后备缓冲器TLB中寻找对应的PTE条目。
这里的TLB有16个组,每组4个条目(四路相联),所以构建时将VPN的低4位取出作为组索引,剩余位作为标记位。通过组索引和行匹配寻找对应的PPN。如果命中,就将其取出,构建下一步使用的PPN。
如果TLB不命中,MMU就通过4级页表从高速缓存中取出相应的PTE,作为PPN构建物理地址,同时在TLB中更新。
多级页表的虚拟地址翻译与单级页表的主要区别在于页表的保存形式。
多级页表从两个角度减少了内存要求。第一,如果一级页表中的一个PTE是空的,那么相应的所有低级页表都不存在,这意味着巨大的潜在节约。第二,只有一级页表才需要总是缓存在主存中,虚拟内存系统可以在需要时创建、页面调入或调出二级页表,这就减少了主存的压力;只有最经常使用的二级页表才需要缓存在主存中。
图7.4.2 物理地址与虚拟地址的转换
7.5 三级Cache支持下的物理内存访问
图7.5.1 使用三级Cache后的VA与PA的变换
MMU发送物理地址PA给L1缓存,L1缓存从物理地址中抽取出缓存偏移CO、缓存组索引CI以及缓存标记CT。高速缓存根据CI找到缓存中的一组,并通过CT判断是否已经缓存地址对应的数据,若缓存命中,则根据偏移量直接从缓存中读取数据并返回;若缓存不命中,则继续从L2、L3缓存中查询,若仍未命中,则从主存中读取数据。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用的时候,内核会为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进场创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本,它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记位私有的写时复制。
当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。既然已经理解了这些概念,我们就能理解execve函数实际上是如何加载和执行程序的。假设运行在当前进程中的程序执行了如下的execve调用:
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的. text和.data区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。图7.7-1概括了私有区域的不同映射。
映射共享区域。hello程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虛拟地址空间中的共享区域内。
设置程序计数器(PC)。execve做的最后- -件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个人口点开始执行。Linux将根据需要换人代码和数据页面。
图7.7.1 页表组成
7.8 缺页故障与缺页中断处理
缺页故障是一种常见的故障,当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。其处理流程遵循图 所示的故障处理流程:
图7.8.1 缺页故障的处理方式
缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。
7.9动态存储分配管理
虽然可以使用低级的mmp和munmap函数来创建和删除虚拟内存的区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器(dynamic memory allocator)更方便,也有更好的可移植性。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个
变量brk(读做“break"),它指向堆的顶部。
分配器将堆视为一-组不同大小的块(block)的集合来维护。每个块就是一一个连续的虛拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
显式分配器(explicit allocator), 要求应用显式地释放任何已分配的块。
隐式分配器(implicit allocator), 另一方面,要求分配器检测-一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbagecollec-tor),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbagecollection)。
在程序运行时程序员使用动态内存分配器(如malloc)获得虚拟内存。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护,每个块要么是已分配的,要么是空闲的。
分配器的类型包括显式分配器和隐式分配器。前者要求应用显式地释放任何已分配的块,后者在检测到已分配块不再被程序所使用时,就释放这个块。
动态内存管理的策略包括:首次适配、下一次适配和最佳适配。
首次适配会从头开始搜索空闲链表,选择第一个合适的空闲块。搜索时间与总块数(包括已分配和空闲块)成线性关系。会在靠近链表起始处留下小空闲块的“碎片”。
下一次适配和首次适配相似,只是从链表中上一次查询结束的地方开始。比首次适应更快,避免重复扫描那些无用块。
最佳适配会查询链表,选择一个最好的空闲块,满足适配,且剩余最少空闲空间。它可以保证碎片最小,提高内存利用率。
分割空闲块:适配到合适的空闲块,分配器将空闲块分割成两个部分是分配块,一个是新的空闲块。
增加堆的空间:通过调用sbrk函数,申请额外的存储器空间,插入到空链表中。
合并空闲块:合并的策略包括立即合并和推迟合并——即合并即释放完就合并,但这样可能导致没必要的分割重复;推迟合并即需要的时候再合并,这样可以避免抖动的产生。
7.10本章小结
本章我们对hello的存储器地址空间,逻辑地址到线性地址再到物理地址以及多级页表和多级缓存有了进一步的了解,知道了hello是如何进行工作的。此外,我们温故而知新,回头重温了一下fork函数和execve函数,发现了二者更多的奥秘。最后对计算机的动态存储分配管理进行了探讨。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有的IO设备都被模型化为文件,而所有的输入输出都被当作对相应文件的读和写来执行。
设备管理:Linux内核的简单的低级接口--unix io接口
文件就是一个字节序列,所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
打开文件: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的实现分析
图8.3.1 printf的函数实现方法
printf接受一个fmt的格式,然后将匹配到的参数按照fmt格式输出,fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。
之后printf调用了外部函数vsprintf,
图8.3.2 vsprintf的函数实现
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
之后vsprintf的输出到write系统函数中。在Linux下,write通过执行syscall指令实现了对系统服务的调用,从而使内核执行打印操作。内核会通过字符显示子程序,根据传入的ASCII码到字模库读取字符对应的点阵,然后通过vram(显存)对字符串进行输出。显示芯片将按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最终实现printf中字符串在屏幕上的输出。
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法,Unix IO接口以及其函数,分析了printf函数和getchar函数。就此,hello彻底完整了,我们也讲完了它平凡而又伟大的一生。
结论
hello,诞生于程序员向.c文件中敲入代码的那一刻,随着代码的逐渐完善,他像所有的新生儿一样,来到了这个世界,以hello.c的身份正式开启了属于他的传奇:
最开始,hello要经过一系列的编译:首先,在预处理器中,hello.c经过预处理,与所有的外部库合体成为了hello.i;然后,再在编译器中经过编译,成为了hello.s;之后,汇编器又将hello.s转换为可重定位的目标文件hello.o;最后,连接器会把hello.o进行链接,于是,可执行的目标程序hello就新鲜出炉了!
在运行程序时,我们在shell中输入./hello 120L020410 徐靖奇 1:shell调用fork函数创建了子进程、调用execve函数进行加载,并最终进入main函数;函数会根据虚拟内存去寻找物理地址,调用相关的函数,并根据需要动态调整堆和栈。
如果,我们在程序执行的时候,向其发送信号,他也会进行相关的操作(诸如:挂起、停止等)。
最后,他在结束后被父进程回收,内核删除他所有的数据,从此,hello传奇的一生也落下了帷幕。
附件
hello: 链接之后的可执行目标文件
hello.c: 源文件
hello.elf: hello的ELF格式
hello.i: 预处理产生的文本文件
hello.o: 汇编产生的可重定位目标文件
hellodump: hello的反汇编代码
hello_odump: hello.o的反汇编代码
hello.s: 编译产生的汇编文件
hello.txt: hello.o的ELF格式
参考文献
[1] 《深入理解计算机系统》