摘 要
本文通过分析hello程序从C文件如何转变为可执行文件的全过程,包括预处理、编译、汇编、链接阶段,每一步如何对上一步形成的文件进行操作,形成新文件的过程。hello进程在shell执行的过程,存储管理的过程,I/O处理的过程。以这些过程的分析为例,阐明整个程序的生命周期。
关键词:预处理;编译;汇编;连接;存储管理
目 录
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 5 -
2.1 预处理的概念与作用 - 5 -
2.2在Ubuntu下预处理的命令 - 5 -
2.3 Hello的预处理结果解析 - 5 -
2.4 本章小结 - 5 -
第3章 编译 - 6 -
3.1 编译的概念与作用 - 6 -
3.2 在Ubuntu下编译的命令 - 6 -
3.3 Hello的编译结果解析 - 6 -
3.4 本章小结 - 6 -
第4章 汇编 - 7 -
4.1 汇编的概念与作用 - 7 -
4.2 在Ubuntu下汇编的命令 - 7 -
4.3 可重定位目标elf格式 - 7 -
4.4 Hello.o的结果解析 - 7 -
4.5 本章小结 - 7 -
第5章 链接 - 8 -
5.1 链接的概念与作用 - 8 -
5.2 在Ubuntu下链接的命令 - 8 -
5.3 可执行目标文件hello的格式 - 8 -
5.4 hello的虚拟地址空间 - 8 -
5.5 链接的重定位过程分析 - 8 -
5.6 hello的执行流程 - 8 -
5.7 Hello的动态链接分析 - 8 -
5.8 本章小结 - 9 -
第6章 hello进程管理 - 10 -
6.1 进程的概念与作用 - 10 -
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
6.3 Hello的fork进程创建过程 - 10 -
6.4 Hello的execve过程 - 10 -
6.5 Hello的进程执行 - 10 -
6.6 hello的异常与信号处理 - 10 -
6.7本章小结 - 10 -
第7章 hello的存储管理 - 11 -
7.1 hello的存储器地址空间 - 11 -
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 -
7.9动态存储分配管理 - 11 -
7.10本章小结 - 12 -
第8章 hello的IO管理 - 13 -
8.1 Linux的IO设备管理方法 - 13 -
8.2 简述Unix IO接口及其函数 - 13 -
8.3 printf的实现分析 - 13 -
8.4 getchar的实现分析 - 13 -
8.5本章小结 - 13 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -
1.什么是Hello程序:
hello程序由用户通过键盘输入,根据高级语言的语法规范形成编译器能够读懂的代码。
完成后的hello.c文件依次经过编译器的预处理对源代码进行转换、编译得到汇编语言代码、汇编再将汇编语言转换为机器语言,最后与库函数进行链接并进行重定位,形成可执行文件。
hello的可执行文件可以通过shell运行并传入命令行参数。shell同样是一个程序,它会通过fork函数为hello创建一个进程,再通过execve执行hello。操作系统的并发机制让hello程序能够与其他程序分片运行。
shell首先fork一个子进程,然后通过execve加载并执行hello,映射虚拟内存,进入程序入口后将程序载入物理内存,进入main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。即,从0开始,以0结束,为020。
2.软件环境:Windows 10 64位; VMware 14;Ubuntu 16.04 LTS 64位;
3.开发与调试工具:GDB;EDB;OBJDUMP;READELF;CodeBlocks 64位;vim/gedit+gcc;
1.hello.c:hello源文件;
2.hello.i:hello.c预编译的结果,用于研究预编译的作用以及进行编译器的下一步编译操作;
3.hello.s:hello.i编译后的结果,用于研究汇编语言以及编译器的汇编操作,可以与hello.c对应,分析底层的实现;
4.hello.o:hello.s汇编后的结果,可重定位目标程序,没有经过链接,用于链接器或编译器链接生成最终可执行程序;
5.hello.out:hello.o链接后生成的可执行目标文件,可以用来反汇编或者通过EDB、GDB等工具分析链接过程以及程序运行过程,包括进入main函数前后发生的过程;
6.hello:同hello.out,由gcc -m64 -no-pie -fno-PIC hello.c -o
hello命令直接生成;
本章简要介绍了hello程序从代码到编译生成,到执行,再到终止的过程与操作系统发生的事件,分析了其P2P和020的过程,列出了本次任务的硬件、软件环境和调试工具,并且列举了任务过程中出现的中间产物及其作用。
1.预处理的概念:
(1)预处理是指预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,比如#include
(2)预处理的结果就得到了另外一个C程序,通常是以.i作为文件扩展名。
2.预处理的作用:
预处理的过程中,对于引用一些封装的库或者代码的这些命令来说,预处理器会读取头文件中用到的库的代码,将这段代码直接插入到程序文件中;对于宏定义来说,会完成对宏定义的替换;注释会直接删除掉。最后将处理过后的新的文本保存在hello.i中。预处理阶段的作用是让编译器在随后对文本进行编译的过程中,更加方便,因为访问库函数这类操作在预处理阶段已经完成,减少了编译器的工作。
命令:gcc -E hello.c -o hello.i
截图如下图(1):
图(1)
通过图(2)hello.c和图(3)hello.i的部分代码对比中可以看出,在原有代码的基础上,将头文件stdio.h的内容引入,例如声明函数、定义结构体、定义变量、定义宏等内容。通过结果分析可以发现,预处理实现了在编译前对代码的初步处理,对源代码进行了某些转换。另外,如果代码中有#define命令还会对相应的符号进行替换。
图(2)
图(3)
预处理是计算机对程序进行操作的第一个步骤,在这个过程中预处理器(preprocessor)会对hello.c文件进行初步的解释,对头文件、宏定义和注释进行操作,将程序中涉及到的库中的代码补充到程序中,将注释这个对于执行没有用的部分删除,最后将初步处理完成的文本保存在hello.i中,方便以后的内核器件直接使用。
1.概念:编译是指编译器(ccl)将文本文件hello.i翻译成文本文件hello.s的过程,这个文本文件内包含了一个汇编语言程序。
2.作用:编译过程编译器实现了经过词法分析、语法分析、语义分析等过程,在检查无错误后将代码翻译成汇编语言。得到的汇编语言代码可供编译器进行生成机器代码、链接等操作。
(1)词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。
(2)基于词法分析得到的一系列记号,生成语法树。
(3)语义分析:由语义分析器完成,指示判断是否合法,并不判断对错。
(4)源代码优化(中间语言生成):中间代码(语言)使得编译器分为前端和后端,前端产生与机器(或环境)无关的中间代码,编译器的后端将中间代码转换为目标机器代码。
(5)代码生成、目标代码优化:编译器后端主要包括:代码生成器:依赖于目标机器,依赖目标机器的不同字长,寄存器,数据类型等。
编译命令:gcc -S hello.i -o hello.s
截图如下图(4):
图(4)
3.3.1 汇编指令:
指令 |
作用 |
.file |
C文件声明 |
.text |
代码段 |
.globl |
声明全局变量 |
.data |
已初始化的全局变量和静态C变量 |
.align 8 |
声明对指令或数据的存放地址进行对齐的方式 |
.type |
指明函数类型或者对象类型 |
.size |
声明变量大小 |
.long .string |
声明long型、string型数据 |
.section .rodata |
只读数据段 |
图(5)所示即为hello.s头部段。
3.3.2 数据
图(6)(7)为编译得到的汇编代码:
图(6)
图(7)
经过编译,在程序运行时会直接通过寻址找到常量。
mov %rbp,%rsp
pop %rbpprintf函数:
(5)getchar函数: call gethcar\@PLT
本章显示介绍了有关编译的概念作用,然后使用#gcc -s hello.c -o hello.s生成了编译后的文件。对于生成的.s文件,我们讲解了C语言的数据与操作在机器之中如何被处理翻译的,汇编代码搭建了从高级语言到底层机器语言的桥梁,实现了对内存数据的各种操作。编译器通过语法分析、语义分析等编译C语言代码到汇编代码,为接下来生成机器代码奠定了基础。
局部变量i:
图(8)
局部变量i第一次在程序中出现在31行处,因为在.c源文件中i被定义为局部变量,将其存储在用户栈中或直接存储在寄存器中。但是31行的操作告知我们i变量在-4(%rbp)位置中,即在用户栈里。
常量:直接用立即数表示。
字符串常量:
图(9)字符串常量。在两个printf中的字符.LC0和.LC1在5-7行声明,字符串存储在.rodata节中。
3.3.3赋值操作
编译器对赋值操作的处理是将其编译为汇编指令MOV。根据不同大小的数据类型有movb、movw、movl、movq、movabsq。
i:i为未初始化的局部变量,其存储在用户栈中,赋值直接使用mov指令即可。
图(10)
变量i的初始化与自增
图(11)
这段汇编代码对应着for(i=0;i<8;i++)中i的初始化与自增。通过“movl $0, -4(%rbp)”指令将-0x4(%rbp)位置的内存赋值为0;通过“addl $1, -4(%rbp)”的指令对内存的值加1;通过“cmpl $7, -4(%rbp)”将内存中的值与7进行比较。经过上面的分析,局部变量的存储在内存中,通过%rbp相对寻址进行读写。
3.3.4算术操作
以hello程序里的“i++”为例,通过ADD操作实现。汇编代码为“addl $1, -4(%rbp)”(图(11)),结果是i的值加1。ADD操作根据不同的数据大小也有不同的操作,根据其后缀可以分辨。其他常见的算术操作指令还有SUB(减)、IMUL(乘)、DIV(除)等。另外对于除法,例如除2,编译器可能优化为逻辑右移从而提高效率而不是使用DIV指令。
3.3.5关系操作
以hello程序里的逻辑关系式“i < 8”为例,编译器通常通过将其编译为CMP指令实现。根据不同的数据大小,有cmpb、cmpw、cmpl和cmpq。在通过CMP指令比较后,在通过jmp指令跳转。
代码如图(12)
图(12)
3.3.6 数组操作
数组,其实就是一段数据类型相同的物理位置相邻的变量集合。例如有5个元素的char数组,地址分为为1、2、3、4、5(仅为了说明相对位置,真实情况地址不会为0x1)。所以对数组的索引实际上就是在第一个元素地址的基础上通过加索引值乘以数据大小来实现。例如整型数组a[0]的地址是address_1,那么a[2]即address_1+ 4 * 2。以hello程序中的命令行参数数组的访问涉及到函数参数传递以及命令行参数的相关知识,较为特殊也比较复杂,但仍能看到相关数组操作的思想:如图(13)命令行参数数组索引所示:
图(13)
char *argv[]
argv单个元素char*大小为8位,argv指针指向已经分配好的、一片存放着字符指针的连续空间,起始地址为argv。c源程序中的数组操作出现在循环体for循环中,每次循环中都要访问argv[1],argv[2]这两个内存。在翻译时,argv[]先是被存在用户栈中,再使用基址加偏移量寻址访问argv[1],argv[2]。
argv[1]:
数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上偏移量$16,再将该位置内容放在%rdx中,成为下一个函数的第一个参数。
argv[2]:
数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上偏移量$8,再将该位置内容放在%rdi中,成为下一个函数的第二个参数。
图(14)
main函数中访问数组元素argv[1],argv[2]时,按照起始地址argv大小8位计算数据地址取数据,在hello.s中,使用两次(%rax)(两次rax分别为argv[1]和argv[2]的地址)取出其值。
3.3.7控制转移
1.if(argc!=4)
程序中控制转移有一处,C语言代码融优图(15)所示:
图(15)
如图(16)所示,对于if判断,编译器使用跳转指令实现,首先使用cmpl“cmpl $4, -20(%rbp)”,设置条件码,使用je判断ZF标志位,如果为0,说明argc==4,则不执行if中的代码直接跳转到.L2,否则顺序执行下一条语句,即执行if中代
图(16)for(i=0;i<8;i++):使用计数变量i循环8次。首先无条件跳转到位于循环体.L4之后的比较代码,使用cmpl进行比较,如果i<=7,则跳入.L4;for循环体执行,否则说明循环结束,顺序执行for之后的逻辑。如图(17)所示。
图(17)
3.3.8函数操作
函数是一种过程,过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。P中调用函数Q包含以下步骤:
传递控制:进行过程Q的时候,PC必须设置为Q的代码的起始地址,然后在返回时,要把PC设置为P中调用Q后面那条指令的地址。
传递数据:P必须能够向Q提供一个或多个参数,Q能够向P中返回一个值。
分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
Hello.c中涉及的函数操作如下:
(1)main函数
(a)传递控制:main函数因为被调用call才能执行(被系统启动函数__libc_start_main调用),call指令将下一条指令的地址压栈,然后跳转到main函数。
(b)传递数据:外部调用过程向main函数传递参数argc和argv,分别使用%edi和%rsi存储,函数正常出口为return 0,将%eax设置0返回。
(c)分配和释放内存:使用%rbp记录栈帧的底,函数分配栈帧空间在%rbp之上,程序结束时,调用leave指令,leave相当于恢复栈空间为调用之前的状态,然后ret返回,ret相当pop IP。一般类似以下代码:
mov %rbp,%rsp
pop %rbp
(2)printf函数:
(a)传递数据:第一次printf将%rdi设置为“Usage: Hello 学号;姓名!\n”字符串的首地址。第二次printf设置%rdi为“Hello %s”;“%s\n”的首地址,设置%rdx为argv[1],%rsi为argv[2]。
(b)控制传递:第一次printf因为只有一个字符串参数,所以“call puts\@PLT”;第二次printf使用“call printf\@PLT”。
(3)exit函数:
(a)传递数据:将%edi设置为1。
(b)控制传递: call exit\@PLT
(4)sleep函数:
(a)传递数据:将%edi设置为sleepsecs。
(b)控制传递: call sleep\@PLT
(5)getchar函数: call gethcar\@PLT
本章显示介绍了有关编译的概念作用,然后使用#gcc -s hello.c -o hello.s生成了编译后的文件。对于生成的.s文件,我们讲解了C语言的数据与操作在机器之中如何被处理翻译的,汇编代码搭建了从高级语言到底层机器语言的桥梁,实现了对内存数据的各种操作。编译器通过语法分析、语义分析等编译C语言代码到汇编代码,为接下来生成机器代码奠定了基础。
1.概念:汇编指的是汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,包含hello程序执行的机器指令。
2.作用:用汇编语言编写的程序,机器不能直接识别,要由一种程序将汇编语言翻译成机器语言,汇编程序起这种翻译作用。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
命令:gcc -c hello.s -o hello.o
截图如图(18)
图(18)
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
4.3.1 ELF头
ELF头以一个16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行、共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。如图(19)所示。
图(19)
4.3.2节头部表
如图(20)所示:
图(20)
4.3.3重定位节
如图(21)所示:从图可以得到hello.o的重定位节信息,可以看到.rodata、puts、exit、printf、sleep、getchar等符号的偏移。链接器会依据重定向节的信息对可重定向的目标文件进行链接得到可执行文件。
图(21)
用命令objdump -d -r hello.o 得到objdump反汇编代码图(22)所示:
图(22)
图(23)
图(24)
图(23)和图(24)是gcc编译结果。
经过对比可以发现,编译与反汇编得到代码都包含程序实现基本的汇编语言代码。编译生成的代码跳转目标通过例如.L2表示,而反汇编的代码通过一个地址表示。
另外,在反汇编的结果中包含了供对照的来自可重定位目标文件中的机器语言代码及相关注释,包括一些相对寻址的信息与重定位信息,尽管没有经过链接不是最终结果,但是能很大程度上反应机器执行的过程。
机器语言由二进制代码构成(图中反汇编结果用16进制表示),是计算机能够直接识别和执行的一种机器指令的集合,是与处理器紧密相关的。机器语言由操作码和操作数组成,操作码与汇编语言符号存在的对应关系。由于操作数类型、寻址方式等的不同,同一个汇编语言符号可能对应着不同的机器语言操作码。
绝对地址或常数情况下,操作数与汇编语言的描述有很明显的直接对应,而相对寻址则需要经过处理。至于分支与函数调用,机器语言均广泛采取相对寻址,通常是下一条指令的地址加偏移量,从而得到绝对地址。
这一章介绍了汇编概念与作用,使用# gcc -c hello.s -o hello.o得到.o文件并使用readelf工具分析可重定位目标elf格式,重点介绍了重定位项目,在objdump操作进行反汇编比较与原汇编语句的不同之处说明机器语言的构成,与汇编语言的映射关系。
1.概念:链接是通过链接器(Linker)将文件中调用的各种函数跟静态库及动态库链接,并将它们打包合并形成目标文件,即可执行文件。可执行文件可以被加载(复制)到内存并执行。
2.作用:通过链接可以实现将头文件中引用的函数并入到程序中,解析未定义的符号引用,将目标文件中的占位符替换为符号的地址。完成程序中各目标文件的地址空间的组织,这可能涉及重定位工作。
注意:这儿的链接是指从 hello.o 到hello生成过程。
图(25)
图(25)链接之后生成可执行文件。
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
ELF头 |
描述生成该文件的系统字的大小和字节顺序 |
段头部表 |
将连续的文件节映射到运行时内存段 |
.init |
程序初始化代码需要调用的函数 |
.text |
已编译程序的机器代码 |
.rodata |
只读数据 |
.data |
已初始化的全局和静态C变量 |
.bss |
未初始化的全局和静态C遍历 |
.symtab |
存放程序中定义和引用的函数和全局变量信息 |
.debug |
条目是局部变量、类型定义、全局变量及C源文件 |
.line |
C源程序中行号和.text节机器指令的映射 |
.strtab |
.symtab和.debug中符号表及节头部中节的名字 |
节头部表 |
描述目标文件的节 |
用命令:readelf -a hello获得以下内容。
可执行目标文件ELF格式如图(26)所示:
图(26)
ELF头如下图(27)(28)所示:
图(27)
图(28)
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
根据图5.3.3得到的各节起始地址,可以在EDB中查找得到对应内容,以下举例说明:
图(29)
图(30)EDB查找部分节起始地址。
图(30)
图(31) .init节
图(31)
图(32) .text节
图(32)
图(33) .data节
图(33)
用命令objdump -d -r hello获得hello,hello.o的反汇编代码。
图(34)是hello的反汇编文件,图(35)是hello.o反汇编的结果。
图(34)
图(35)
经比较,hello.o反汇编结果与hello反汇编结果在以下几个方面存在不同:
hello.o没有经过链接,所以main的地址从0开始,并且不存在调用的如printf这样函数的代码。
很多地方都有重定位标记,用于后续的链接过程。hello.o反汇编代码的相对寻址部分的地址也不具有参考性,没有经过链接并不准确。
而链接后的hello程序地址已经生成,main的地址也不再是0。从汇编代码可以看到,库函数的代码都已经链接到了程序中,程序各个节变得更加完整,跳转的地址也具有参考性。
总之,经过链接,函数的个数增加,头文件的函数加入至代码中;各类相对寻址确定,动态库函数指向PLT;函数的起始地址也得到了确定。
加载程序 |
ld-2.23.so!_dl_start ld-2.23.so!_dl_init LinkAddress!_start libc-2.23.so!_libc_start_main libc-2.23.so!_cxa_atexit LinkAddress!_libc_csu.init libc-2.23.so!_setjmp
|
call main |
LinkAddress!main |
程序终止 |
libc-2.23.so!exit |
程序名称 |
程序地址 |
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
|
(1)动态链接库中的函数在程序执行的时候才会确定地址,所以编译器无法确定其地址,在汇编代码中也无法像静态库的函数那样体现。hello程序对动态链接库的引用,基于数据段与代码段相对距离不变这一个机理,因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量。
(2)动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。如果一个目标模块调用定义在共享库中的任何函数,那么就有自己的GOT和PLT。
(3)PLT是一个数组,其中每个条目是16字节代码。每个库函数都有自己的PLT条目,PLT[0]是一个特殊的条目,跳转到动态链接器中。从PLT[2]开始的条目调用用户代码调用的函数。
(4)GOT也是一个数组,每个条目是8字节的地址,和PLT联合使用时,GOT[2]是动态链接在ld-linux.so模块的入口点,其余条目对应于被调用的函数,在运行时被解析。每个条目都有匹配的PLT条目。
如图(36)、(37)所示,global_offset表在链接前后的变化。
图(36) do_init之前:
图(36)
图(37) do_init之后:
图(37)
本章分析了链接过程中对程序的处理。Linux系统使用可执行可链接格式,即ELF,具有.text,.rodata等节,并且通过特定的结构组织。经过链接,ELF可重定位的目标文件变成可执行的目标文件,链接器会将静态库代码写入程序中,以及调用动态库等相关信息,将地址进行重定位,从而保证寻址的正确进行。静态库直接写入代码即可,而动态链接过程相对复杂一些,涉及共享库的寻址。链接后,程序便能够在作为进程通过虚拟内存机制直接运行。
1.概念:一个正在执行的程序的示例,是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
2.作用:提供给应用进程两个关键抽象(1)一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。(2)一个私有的地址空间,它提供一个假象,好像我们程序在独占整个内存系统。
1.作用:Shell俗称壳,是指“为使用者提供操作界面”的软件(命令解析器),它接收用户命令,然后调用相应的应用程序。
2.处理流程:
(1)打印提示信息
(2)等待用户输入
(3)接受命令
(4)解释命令
(5)找到该命令,执行命令,如果命令含有参数,输入的命令解释它
(6)执行完成,返回第一步
shell作为父进程通过fork函数为hello创建一个新的进程,供其执行。通过fork函数,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本。
图(38)
shell判断它不是内置命令,于是会加载并运行当前目录下的可执行文件hello。此时shell通过fork创建一个新的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库和用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。子进程与父进程最大的区别就是有不同的pid。fork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0.父进程与子进程是并发运行的独立进程。
创建进程后,在子进程中通过判断pid即fork()函数的返回值,判断处于子进程,则会通过execve函数在当前进程的上下文中加载并运行一个新程序。execve加载并运行可执行目标文件,且带参数列表argv和环境变量列表envp。只有当出现错误时,execve才会返回到调用程序。
在execve加载了可执行程序之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,即可执行程序的main函数。此时用户栈已经包含了命令行参数与环境变量,进入main函数后便开始逐步运行程序。
在hello执行之前,内核被其他进程调用,接着在某些时刻,内核可以决定抢占当前进程,并开始一个先前被抢占了的进程(如hello),hello就可以被执行。这种决策就叫做调度,是由内核中的调度器代理的。在内核调度hello后,它就抢占当前进程并使用上下文切换转移控制到hello进程。
上下文是内核重新启动一个被抢占的进程所需要的状态。它由一些对像的值组成,如寄存器,用户栈,内核栈,内核数据结构。而上下文切换就是先保存当前进程的上下文,再恢复hello进程的上下文,最后将控制转移到hello进程。
图(39)
如上图sleep导致的上下文切换(A为hello)。当hello进程每次执行到sleep时,程序陷入休眠状态,内核调度其他进程,而sleep函数结束后,hello进程重新进入待执行进程队列中等待内核调度。
图(40)
上图getchar导致的上下文切换(A为hello)。
内核调度后,执行到getchar函数时,调用read函数,陷入到内核。内核中的陷阱处理程序请求来自磁盘处理器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。内核调度其他进程。之后当磁盘发出一个中断信号,表示数据已经从磁盘传送到了内存。内核判断其他进程已经运行了它的足够长时间,就执行一个从其他进程到进程hello的上下文切换,将控制传递到read之后的指令,进程hello继续执行。
1.hello执行过程中可能出现四类异常:中断、陷阱、故障和终止。
(1)中断是来自I/O设备的信号,异步发生,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
(2)陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
(3)故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
(4)终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。
2.hello执行过程中,可能会遇到各种异常,信号则是一种通知用户异常发送的机制。例如较为底层的硬件异常以及较高层的软件事件,比如Ctrl-Z和Ctrl-C,分别触发SIGCHLD和SIGINT信号。
(1)正常运行结果如图(41)所示:
图(41)
图(42)展示了运行时乱按时的结果,乱按的输入并不会影响进程的执行,当按到回车键时,getchar会读入回车符,并且后面的字符串会当作shell的命令行输入。
图(42)
图(43)展示了运行时按Ctrl+C。父进程收到SIGINT信号,终止hello进程,并且回收hello进程。
图(43)
图(44)展示了运行时按下Ctrl+Z后运行pstree命令。pstree命令是以树状图显示进程间的关系。
图(44)
Ctrl-z后可以运行ps ,结果如图(45)所示:
图(45)
本章介绍了程序在shell执行及进程的相关概念。程序在shell中执行是通过fork函数及execve创建新的进程并执行程序。进程拥有着与父进程相同却又独立的环境,与其他系统进并发执行,拥有各自的时间片,在内核的调度下有条不紊的执行着各自的指令。程序运行中难免遇到异常,异常分为中断、陷阱、故障和终止四类,均有对应的处理方法。操作系统提供了信号这一机制,实现了异常的反馈。这样,程序能够对不同的信号调用信号处理子程序进行处理。
Intel处理器从逻辑地址到线性地址的变换通过段式管理,介绍段式管理就必须了解段寄存器的相关知识。段寄存器对应着内存不同的段,有栈段寄存器(SS)、数据段寄存器(DS)、代码段寄存器(CS)和辅助段寄存器(ES/GS/FS)。
索引号就是“段描述符(segment descriptor)”的索引,段描述符具体地址描述了一个段。很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这句话很关键,说明段标识符的具体作用,每一个段描述符由8个字节组成。
Base字段,表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。那究竟什么时候该用GDT,什么时候该用LDT呢?这是由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT,GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。
分段功能在实模式和保护模式下有所不同。
(1)实模式:即不设防,说逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。
(2)保护模式:线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。段寄存器无法放下32位段基址,所以它们被称作选择符,用于引用段描述符表中的表项来获得描述符。描述符表中的一个条目描述一个段,如下图所示:
图(46)
所有段描述符被保存在两个表中:全局描述符表(GDT)和局部描述符表(LDT)。电脑中的每一个CPU(或一个处理核心)都含有一个叫做gdtr的寄存器,用于保存GDT的首个字节所在的线性内存地址。为了选出一个段,必须向段寄存器加载以上格式的段选择符。
在保护模式下,分段机制就可以描述为:通过解析段寄存器中的段选择符在段描述符表中根据Index选择目标描述符条目Segment。Descriptor,从目标描述符中提取出目标段的基地址Base address,最后加上偏移量offset共同构成线性地址Linear Address。
线性地址(即虚拟地址VA)到物理地址(PA)之间的转换通过分页机制完成。而分页机制是对虚拟地址内存空间进行分页。
Linux系统有自己的虚拟内存系统,其虚拟内存组织形式如图7-3所示,Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。
图(47)为 Linux组织虚拟内存。
系统将每个段分割为被称为虚拟页(VP)的大小固定的块来作为进行数据传输的单元,在linux下每个虚拟页大小为4KB,类似地,物理内存也被分割为物理页(PP/页帧),虚拟内存系统中MMU负责地址翻译,MMU使用存放在物理内存中的被称为页表的数据结构将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
如图(48),不考虑TLB与多级页表,虚拟地址分为虚拟页号VPN和虚拟页偏移量VPO,根据位数限制分析可以确定VPN和VPO分别占多少位是多少。通过页表基址寄存器PTBR+VPN在页表中获得条目PTE,一条PTE中包含有效位、权限信息、物理页号,如果有效位是0+NULL则代表没有在虚拟内存空间中分配该内存,如果是有效位0+非NULL,则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中,如果有效位是1则代表该内存已经缓存在了物理内存中,可以得到其物理页号PPN,与虚拟页偏移量共同构成物理地址PA。
图(48)使用页表的地址翻译。
在Intel Core i7环境下研究VA到PA的地址翻译问题。前提如下:
虚拟地址空间48位,物理地址空间52位,页表大小4KB,4级页表。TLB 4路16组相联。CR3指向第一级页表的起始位置(上下文一部分)。
解析前提条件:由一个页表大小4KB,一个PTE条目8B,共512个条目,使用9位二进制索引,一共4个页表共使用36位二进制索引,所以VPN共36位,因为VA 48位,所以VPO 12位;因为TLB共16组,所以TLBI需4位,因为VPN 36位,所以TLBT32位。
如图(49)所示,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的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
图(49)Core i7四级页表下地址翻译情况。
由于L1、L2、L3各级Cache的原理相同,只做L1 Cache的分析。L1 Cache是8路64组相连高速缓存。块大小64B。因为有64组,所以需要6 bit CI进行组寻址,共有8路,块大小为64B,所以需要6 bit CO表示数据偏移位置,因为VA共52 bit,所以CT共40 bit。在上一步中已经获得了物理地址VA,使用CI进行组索引,每组8路,对8路的块分别匹配CT(前40位)。如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO(后六位)取出数据返回。
如果没有匹配成功或者匹配成功但是标志位是0,则不命中(miss),向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。如图(50)所示。
图(50)为三级Cache支持下的物理内存访问。
shell通过fork为hello创建新进程。当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给hello进程唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和样表的原样副本。它将两个进程中的每个页面都标记为只读,并将每个进程中的每个区域结构都标记为写时复制。当fork在新进程中返回时,新进程现在的虚拟内存刚好的和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就是为每个进程保持了私有地址空间的概念。
execve函数在shell中加载并运行包含在可执行文件hello中的程序,用hello程序有效地替代了当前程序。加载hello的过程主要步骤如下:首先删除已存在的用户区域,也就是将shell与hello都有的区域结构删除。然后映射私有区域,即为新程序的代码、数据、bss和栈区域创建新的区域结构,均为私有的、写时复制的。下一步是映射共享区域,将一些动态链接库映射到hello的虚拟地址空间,最后设置程序计数器,使之指向hello程序的代码入口。经过这个内存映射的过程,在下一次调度hello进程时,就能够从hello的入口点开始执行了。
动态存储分配管理由动态内存分配器完成。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆是一个请求二进制零的区域,它紧接在未初始化的数据区后开始,并向上生长(向更高的地址)。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显示地被应用程序所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。动态内存分配器从堆中获得空间,将对应的块标记为已分配,回收时将堆标记为未分配。而分配和回收的过程中,往往涉及到分割、合并等操作。动态内存分配器的目标是在对齐块的基础上,尽可能地提高吞吐率及空间占用率,即减少因为内存分配造成的碎片。其实现常见的数据结构有隐式空闲链表、显式空闲链表、分离空闲链表,常见的放置策略有首次适配、下一次适配和最佳适配。
为了更好的介绍动态存储分配的实现思想,以隐式空闲分配器的实现原理为例进行介绍:
图(51) 隐式空闲链表堆块结构。
隐式空闲链表分配器的实现涉及到特殊的数据结构。其所使用的堆块是由一个子的头部、有效载荷,以及可能的一些额外的填充组成的。头部含有块的大小以及是否分配的信息。有效载荷用来存储数据,而填充块则是用来对付外部碎片以及对齐要求。基于这样的基本单元,便可以组成隐式空闲链表。
图(52)隐式空闲链表结构。
通过头部记录的堆块大小,可以得到下一个堆块的大小,从而使堆块隐含地连接着,从而分配器可以遍历整个空闲块的集合。在链表的尾部有一个设置了分配位但大小为零的终止头部,用来标记结束块。当请求一个k字节的块时,分配器搜索空闲链表,查找足够大的空闲块,其搜索策略主要有首次适配、下一次适配、最佳适配三种。一旦找到空闲块,如果大小匹配的不是太好,分配器通常会将空闲块分割,剩下的部分形成一个新的空闲块。如果无法搜索到足够空间的空闲块,分配器则会通过调用sbrk函数向内核请求额外的堆内存。当分配器释放已分配块后,会将释放的堆块自动与周围的空闲块合并,从而提高空间利用率。为了实现合并并保证吞吐率,往往需要在堆块中加入脚部进行带边界标记的合并。
本章讨论了存储器地址空间,虚拟地址、物理地址、线性地址、逻辑地址的概念,TLB与四级页表支持下的VA到PA的变换,三级Cache支持下的物理内存访问,hello进程fork时和execve时的内存映射,缺页故障与缺页中断处理和动态存储分配管理。
一个Linux文件就是一个m个字节的序列,所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这个设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得输入和输出都能以一种统一且一致的方式的来执行。
一个应用程序通过要求内核打开相应的文件来宣告它想访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,而文件的相关信息由内核记录,应用程序只需要记录这个描述符。
Linux shell创建的每个进程开始时都包含标准输入、标准输出、标准错误三个文件,供其执行过程中使用。对于每个打开的文件,内核保持着一个文件位置k,初始为0,即从文件开头起始的字节偏移量,应用程序能够通过执行seek操作来显式的改变其值。至于读操作,就是从文件复制n个字节到内存,并将文件位置k增加为k + n。当k大于等于文件大小时,触发EOF条件,即读到文件的尾部。最后,在结束对文件的访问后,会通过内核关闭这个文件,内核将释放打开这个文件时创建的数据结构,并将描述符恢复到可用的描述符池中。
Linux以文件的方式对I/O设备进行读写,将设备均映射为文件。对文件的操作,内核提供了一种简单、低级的应用接口,即Unix I/O接口。
Unix I/O接口提供了以下函数供应用程序调用:
(1)打开文件:int open(char *filename, int flags, mode_t mode);
(2)关闭文件:int close(int fd);
(3)读文件:ssize_t read(int fd, void *buf, size_t n);
(4)写文件:ssize_t write(int fd, const void *buf, size_t n);
Linux下printf函数的实现:
static int printf(const char *fmt, ...)
{
va_list args;
int i;
va_start(args, fmt);
write(1,printbuf,i=vsprintf(printbuf, fmt, args));
va_end(args);
return i;
}
其中*fmt是格式化用到的字符串,而后面省略的则是可变的形参,即printf(“%d”, i)中的i,对应于字符串里面的缺省内容。
va_start的作用是取到fmt中的第一个参数的地址,下面的write来自Unix I/O,而其中的vsprintf则是用来格式化的函数。这个函数的返回值是要打印出的字符串的长度,也就是write函数中的i。该函数会将printbuf根据fmt格式化字符和相应的参数进行格式化,产生格式化的输出,从而write能够打印。
在Linux下,write函数的第一个参数为fd,也就是描述符,而1代表的就是标准输出。查看write函数的汇编实现可以发现,它首先给寄存器传递了几个参数,然后调用syscall结束。write通过执行syscall指令实现了对系统服务的调用,从而使内核执行打印操作。
内核会通过字符显示子程序,根据传入的ASCII码到字模库读取字符对应的点阵,然后通过vram(显存)对字符串进行输出。显示芯片将按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最终实现printf中字符串在屏幕上的输出。
getchar的实现大体如下:
int getchar(void)
{
char c;
return (read(0,&c,1)==1)?(unsigned char)c:EOF
}
getchar函数通过调用read函数返回字符。其中read函数的第一个参数是描述符fd,0代表标准输入。第二个参数输入内容的指针,这里也就是字符c的地址,最后一个参数是1,代表读入一个字符,符号getchar函数读一个字符的设定。read函数的返回值是读入的字符数,如果为1说明读入成功,那么直接返回字符,否则说明读到了buf的最后。read函数同样通过sys_call中断来调用内核中的系统函数。键盘中断处理子程序会接受按键扫描码并将其转换为ASCII码后保存在缓冲区。然后read函数调用的系统函数可以对缓冲区ASCII码进行读取,直到接受回车键返回。
这样,getchar函数通过read函数返回字符,实现了读取一个字符的功能。
本章简述了Linux的I/O设备管理机制,Unix I/O接口及函数,并简要分析了printf函数和getchar函数的实现。
列出所有的中间产物的文件名,并予以说明起作用。
文件名称 |
文件说明 |
hello.c |
hello源文件 |
hello.i |
预处理后文本文件 |
hello.s |
编译得到的汇编文件 |
hello.o |
汇编后的可重定位目标文件 |
hello |
链接后可执行文件 |
hello.elf |
hello的elf文件 |
helloo.objdump |
hello.o(链接前)的反汇编文件 |
hello_o_elf.txt |
hello.o的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.