目 录
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 5 -
1.3 中间结果 - 5 -
1.4 本章小结 - 5 -
第2章 预处理 - 6 -
2.1 预处理的概念与作用 - 6 -
2.2在Ubuntu下预处理的命令 - 6 -
2.3 Hello的预处理结果解析 - 4 -
2.4 本章小结 - 7 -
第3章 编译 - 8 -
3.1 编译的概念与作用 - 8 -
3.2 在Ubuntu下编译的命令 - 8 -
3.3 Hello的编译结果解析 - 9 -
3.4 本章小结 - 11 -
第4章 汇编 - 16 -
4.1 汇编的概念与作用 - 16 -
4.2 在Ubuntu下汇编的命令 - 16 -
4.3 可重定位目标elf格式 - 16 -
4.4 Hello.o的结果解析 - 19 -
4.5 本章小结 - 19 -
第5章 链接 - 20 -
5.1 链接的概念与作用 - 20 -
5.2 在Ubuntu下链接的命令 - 20 -
5.3 可执行目标文件hello的格式 - 21 -
5.4 hello的虚拟地址空间 - 23 -
5.5 链接的重定位过程分析 - 23 -
5.6 hello的执行流程 - 24 -
5.7 Hello的动态链接分析 - 24 -
5.8 本章小结 - 25 -
第6章 hello进程管理 - 27 -
6.1 进程的概念与作用 - 27 -
6.2 简述壳Shell-bash的作用与处理流程 - 27 -
6.3 Hello的fork进程创建过程 - 27 -
6.4 Hello的execve过程 - 28 -
6.5 Hello的进程执行 - 28 -
6.6 hello的异常与信号处理 - 30 -
6.7本章小结 - 32 -
第7章 hello的存储管理 - 33 -
7.1 hello的存储器地址空间 - 33 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 33 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 34 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 35 -
7.5 三级Cache支持下的物理内存访问 - 35 -
7.6 hello进程fork时的内存映射 - 36 -
7.7 hello进程execve时的内存映射 - 36 -
7.8 缺页故障与缺页中断处理 - 36 -
7.9动态存储分配管理 - 37 -
7.10本章小结 - 38 -
第8章 hello的IO管理 - 40 -
8.1 Linux的IO设备管理方法 - 40 -
8.2 简述Unix IO接口及其函数 - 40 -
8.3 printf的实现分析 - 41 -
8.4 getchar的实现分析 - 43 -
8.5本章小结 - 43 -
结论 - 44 -
附件 - 45 -
参考文献 - 46 -
1)P2P简介
程序员用IDE等相关工具编写hello.c程序(文本文件);在Linux操作系统里,预处理器根据以字符#开始的命令修改hello.c得到另一个C程序hello.i(文本文件);编译器将hello.i翻译成文本文件hello.s(文本文件);汇编器翻译得到可重定位目标文件hello.o;经过链接(ld)生成hello(可执行目标程序)。程序员在Shell输入./hello执行此程序,hello最后变成了系统里的一个进程。
在Shell处理Hello过程中,shell会fork一个子进程,并在这个子进程中调用execve加载hello。然后程序会跳转到_start地址,最终调用hello的main函数。打印完hello后程序结束。最后shell回收此进程。
实验中所用的hello.c代码如下:
图1.1 Hello源程序
硬件环境:Intel® Core™ i7-8550U CPU;16.00GB RAM; 512GSSD
软件环境:Vmware Workstation 14 Pro;Ubuntu 16.04 LTS 64位;Windows 10 64位;
开发工具:CodeBlocks ;Visual Studio Code;GCC;objdump;EDB;readelf;hexedit;vim;Ld;
中间结果文件 |
文件作用 |
使用时期 |
Hello1.i |
预处理 |
第二章-预处理 |
Hello1.s |
编译成汇编语言之后的程序文本 |
第三章-编译 |
Hello1.o |
hello.s生成的二进制文件 |
第四章-汇编 |
Hello1.asm |
反汇编结果 |
第四章-汇编 |
Hello1.elf |
hello.o的elf文件 |
第四章-汇编 |
Hello1 |
可执行二进制文件 |
第五章-链接 |
hello_elf1 |
hello的elf文件 |
第五章-链接 |
本章分析本论文所要研究的全过程,列出了中间结果还有所使用的软硬件情况。
预处理的概念
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。
在编译之前进行的处理。 C语言的预处理主要有三个方面的内容:
预处理的作用
预处理过程扫描源代码,对预处理命令进行转换,插人所有用#include命令指定的文件,得到新的文本代码。预处理过程还会删除程序中的注释和多余的空白字符。
预处理命令:gcc -E hello.c -o hello1.i
图2.2 输出预编译文本文件
根据#include
上图插入的代码用来描述使用的运行库在计算机中的位置。
用来声明可能使用到的函数的名字。
预处理操作把这些东西全都塞到了hello.i文本中。不过这样子也的确方便了编译器对他进行翻译成汇编语言的操作。
本章节简单介绍了c语言在编译前的预处理过程,简单介绍了预处理过程的概念和作用,对预处理过程进行演示,并举例说明预处理的结果还有解析预处理的过程。
编译的概念
编译器(ccl)将文本文件hello.i翻译咸文本文件hello.s,它包含一个汇编语言程序。在本实验中,编译会将将预处理得到的中间文件hello1.i翻译成汇编语言文件hello1.s。
编译的作用
(1)词法分析:词法分析阶段是编译过程的第一个阶段。这个阶段的任务是从左到右一个字符一个字符地读入源程序,即对构成源程序的字符流进行扫描然后根据构词规则识别单词(也称单词符号或符号)。
(2)语法分析:语法分析是编译过程的一个逻辑阶段。语法分析的任务是在词法分析的基础上将单词序列组合成各类语法短语,如“程序”,“语句”,“表达式”等等.语法分析程序判断源程序在结构上是否正确.源程序的结构由上下文无关文法描述。
(3)语义分析:语义分析是编译过程的一个逻辑阶段. 语义分析的任务是对结构上正确的源程序进行上下文有关性质的审查, 进行类型审查.
(4)优化后生成相应的汇编代码
编译的命令:gcc -S hello1.i -o hello1.s
图3.2 .i编译生成.s文件
3.3.1hello.s文件分析:
内容 |
含义 |
.filet |
源文件名 |
.text |
代码段 |
.globl |
全局变量 |
.secetion .rodata |
rodata节 |
.align |
对齐方式 |
.long |
长整型 |
.string |
字符串 |
.size |
声明大小 |
.type |
指定是对象类型或是函数类型 |
3.3.2hello.s文件使用的字符串:
程序中的字符串分别是:
1、“用法: Hello 学号 姓名!\n”,可以发现字符串被编码成UTF-8格式,一个汉字在utf-8编码中占三个字节。声明在.LC0段。.LC0保存在rodata中。
2、“Hello %s %s\n”。声明在.LC1段。.LC1也保存在rodata中。
图3.2-2 hello.s中的字符串数据类型使用
3.3.3hello.s文件使用的整数:
1、sleepescs: hello定义了sleepescs全局变量,存放在.data节中(.data存放已经初始化的全局和静态变量)。可以看见.data段中,设置对齐方式为4、设置类型为object、大小为4字节、设置为long类型其值为2
图 hello.s中的全局变量
2 int i : hello定义了int I 局部变量,保存在寄存器或者栈帧中,通过对.L2的猜测,看出i局部变量在栈帧中占据了4字节。
图 hello.s中的int数据类型使用
3、立即数:源程序中使用的常数直接硬编码在汇编语言中,使用$符号标出。
图 hello.s中的立即数
4、int argc :hello程序订了argc 作为main 函数的形式参数。存储在%edi中。
图3.3 hello.s中的argc形式参数
3.3.3hello.s文件使用的数组:
Hello 程序定义了数组类型argv[1]和argv[2]。都声明在.rodata只读数据段中。当我们在shell中输入参数后,argv存储该参数。在Hello.s中,通过在栈帧中的操作:
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
可以取到我们在shell中输入的参数,即argv[1]和argv[2]。并且由此判断数组中一个元素大小为8个字节。
图3.3-2 hello.s中的argv数组
3.3.4赋值
程序中涉及的赋值操作有:
1、int sleepsecs=2.5 :因为sleepsecs是全局变量,所以直接在.data节中将sleepsecs声明为值2的长整型。
2、i=0:通过汇编语句
movl $0, -4(%rbp)
将立即数赋值给我们的局部变量int i
3.3.5类型转换
当sleepsces赋值为2.5时,因为2.5是浮点数类型,sleepsecs是int型,因此向零舍入hello.s中的sleepsecs隐式地被赋值为2 。
3.3.6算数操作
汇编语言中有以下指令。指令效果和描述如下表:
指令 |
效果 |
描述 |
1eaq S,D |
D←&S |
加载有效地址 |
INC D DEC D NEG D NOT D |
D←D十1 D←D-1 D←一D D←一D |
加1 减l 取负 取补 |
ADD s,D |
D←D十s |
加 |
SUB S,D |
D←D-S |
减 |
IMUL S,D |
D←D*S |
乘 |
XOR S,D |
D←D^S |
异或 |
OR S,D |
D←D|S |
或 |
AND S,D |
D←D&S |
与 |
SAL k,D |
D←D< |
左移 |
SHL k,D |
D←D< |
左移 |
SAR k,D |
D←D>>Ak |
算术右移 |
SHR k,D |
D←D< |
逻辑右移 |
Hello程序涉及的算数操作具体有:
1、i++:过汇编语句addl $1, -4(%rbp)实现;
2、subq $32, %rsp。Rsp指针向下减,为栈帧开辟空间;
3、leaq .LC1(%rip), %rdi:计算printf函数中格式串的地址,并传给%rdi。
3.3.7关系操作
Hello程序涉及的算数操作具体有:
1、i<8: 相关的汇编语句为cmpl $7, -4(%rbp)。编译器i<8将其优化为i<=7
2、argc!=3:相关的汇编语句为 cmpl $3, -20(%rbp); 的编译器将!=3时执行,优化为==3判断为真时跳转。
3.3.8数组/指针/结构操作
通过mov指令实现数据传送。完成数组、指针和结构操作。例如对于argv[1]和argv[2]利用在栈帧中位置,通过(%rax)和%rax+8,分别得到argv[1]和argc[2]两个字符串,取出所在内存中的内容。
图3.3-4 指针数组argv
3.3.9控制转移
1、if(argc!=3) 相关汇编语句为cmpl $3, -20(%rbp)
通过设置条件码,判断ZF零标志,如果最近的操作得出的结果为0,则跳到.L2中,否则顺序执行下一条语句。
2、for(i=0;i<10;i++): 通过cmpl $7, -4(%rbp) 汇编语句进行判断然后进行jle .L4跳转。
图3.3-5 for循环的控制转移
3.3.10函数操作
1.main函数:
参数传递:传入参数argc和argv,分别用寄存器%rdi和%rsi存储。
函数调用:main函数因为被调用call才能执行(被系统启动函数__libc_start_main调用),call指令将下一条指令的地址dest压栈,然后跳转到main函数。
函数返回:外部调用过程向main函数传递参数argc和argv,分别使用%rdi和%rsi存储,函数正常出口为return 0,将%eax设置0返回。
2.printf函数:
参数传递:call puts时只传入了字符串参数首地址;for循环中call printf时传入了 argv[1]和argc[2]的地址。
函数调用:for循环中被调用
3.exit函数:
参数传递:传入的edi寄存器值为1,再执行退出命令
函数调用:call exit@PLT。
4.sleep函数:
参数传递:传入参数sleepsecs,传递控制call sleep
函数调用:call sleep@PLT
5.getchar
传递控制:call getchar
函数调用:call gethcar@PLT
本章主要介绍了编译的概念与作用,介绍了相关编译的指令, 本章对hello.s汇编文件进行了分析和探讨,分析了其数据类型及相关操作、算数操作、关系操作、函数操作和控制转移。通过对编译的结果进行解析,更深刻地理解了C语言的数据与操作,并且对C语言翻译成汇编语言有了更好的掌握
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它包含的是程序的指令编码。
汇编命令:gcc -c hello.s -o hello.o
图4.2 汇编命令
4.3.1读取可重定位目标文件
使用命令 readelf -a hello.o >hello.elf将elf可重定位目标文件输出定向到文本文件hello.elf中
图4.3 readelf指令以及生成的elf文件
4.3.2 ELF可重定位目标文件
ELF头 |
节 |
.text |
|
.rodata |
|
.data |
|
.bss |
|
.symtab |
|
.rel.text |
|
.rel.data |
|
.debug |
|
.line |
|
.strtab |
|
节头部表 |
描述目标 文件的节 |
ELF 头:以 16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。
图4.3-2 ELF头
.text:已编译程序的机器代码。
.rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表。
.data:已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss中。
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态C变量。
.symtab:一个符号表,他存放在程序中定义和引用的函数和全局变量的信息
节头部表:节头表包括节名称,节的类型,节的属性(读写权限),节在ELF文件中所占的长度以及节的对齐方式和偏移量。
重定位是将EFL文件中的未定义符号关联到有效值的处理过程。在main.o中,这意味着对printf,exit等函数的未定义的引用和全局变量(sleepsecs)必须替换为该进程的虚拟地址空间中适当的机器代码所在的地址。在目标中用到的相关符号之处,都必须替换。
两种最基本的重定位类型:
R_X86_64_PC32 :重定位一个使用32位PC相对地址的引用。
R_X86_64_32 :重定位一个使用32位PC绝对地址的引用。
图4.3-3 节头表
使用 objdump -d -r hello1.o > helloo.objdump获得反汇编代码。
分支转移:反汇编代码跳转指令的操作数使用的不是段名称如.L3,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
函数调用:在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待静态链接的进一步确定。
全局变量访问:在.s文件中,访问rodata(printf中的字符串),使用段名称+%rip,在反汇编代码中0+%rip,因为rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
图4.4 汇编与反汇编对比
这一章我们介绍了汇编概念与作用,使用# gcc -c hello.s -o hello.o得到.o文件并使用readelf工具分析可重定位目标elf格式,重点介绍了重定位项目,在objdump操作进行反汇编比较与原汇编语句的不同之处说明机器语言的构成,与汇编语言的映射关系。
概念:将一个或多个由编译器或汇编器生成的目标文件外加库链接为一个可执行文件。目标文件是包括机器码和链接器可用信息的程序模块。
作用:链接器的工作就是解析未定义的符号引用,将目标文件中的占位符替换为符号的地址。链接器还要完成程序中各目标文件的地址空间的组织,这可能涉及重定位工作。
命令: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 ld链接命令
在ELF格式中,节头部表(Section Headers)对hello的各个节的信息做了说明,包括各段的起始地址(Address),大小(size),类型(Type),偏移(Offset)对齐要求(Align)等信息,如下图:
图5.2 hello各节的基本信息
链接器在链接可执行文件或动态库的过程中,它会把来自不同可重定位对象文件中的相同名称的 section 合并起来构成同名的 section。接着,它又会把带有相同属性(比方都是只读并可加载的)的 section 都合并成所谓 segments(段)。segments 作为链接器的输出,常被称为输出section。一个单独的 segment 通常会包含几个不同的 sections,比方一个可被加载的、只读的segment 通常就会包括可执行代码section .text、只读的数据section .rodata以及给动态链接器使用的符号section .dymsym等等。section 是被链接器使用的,但是 segments是被加载器所使用的。加载器会将所需要的 segment 加载到内存空间中运行。和用sections header table 来指定一个可重定位文件中到底有哪些 sections一样。在一个可执行文件或者动态库中,也需要有一种信息结构来指出包含有哪些segments。这种信息结构就是 program header table,如下图:
图5.3 各个段包含的节
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
以代码段和PHDR段为例进行对比分析,代码段偏移为0,被映射到虚拟地址0x0x0000000000400040,位于hello开头的代码被加载到虚拟地址为0x400000的地方,同理PFDR段位于偏移0x40处,被加载到了0x400040处。
图5.4 虚拟空间地址
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。结合hello.o的重定位项目,分析hello中对其怎么重定位的。
1.hello与hello.o的不同:
链接器在链接可执行文件或动态库的过程中,它会把来自不同可重定位对象文件中的相同名称的 section 合并起来构成同名的 section,在这里因为链接的时候指定了/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o,所以将会把这些.o文件的每个节与hello.o的节合并。在合并的过程中,会根据重定位信息对相应的地方进行重定位,比如hello.o中的.text节和.data节,接着,它又会把带有相同属性(比方都是只读并可加载的)的 section 都合并成所谓 segments(段)。segments 作为链接器的输出,常被称为输出section。所以说hello.o只是hello的一部分。通过将两者的反汇编代码进行比对也能看出这一点,hello的反汇编代码中也比hello.o的反汇编代码节要多:.init .plt .text .fini,在这些节中多出了一些main函数运行必要的函数:_start,_init,__libc_csu_init,__libc_csu_fini,__libc_start_main,和在hello中调用的函数:printf、sleep、getchar、exit函数。
2.hello是如何进行重定位的:
在4.3节中我们举了一个具体的例子来说明重定位条目是如何来指导进行重定位的,这里我们继续这个例子来说明:
①首先计算需要被重定位的位置
refptr = .text + r.offset = 0x4005e7 + 0x1b = 0x400602
②然后链接器计算出运行时需要重定位的位置:
refaddr = ADDR(.text) + r.offset = 0x4005e7+0x1b = 0x400602
③然后更新该位置
*refptr = (unsigned) (ADDR(r.symbol) + r.addend-refaddr)
使用edb执行hello,从加载hello到_start,到call main,以及程序终止的主要过程如下:
函数名 |
地址 |
ld-2.27.so!_dl_start |
0x7f96ed2e1ea0 |
ld-2.27.so!_dl_init |
|
hello!_start |
0x400500 |
libc-2.27.so!__libc_start_main |
0x7fbdf0cccab0 |
hello!puts@plt |
0x400410 |
hello!exit@plt |
|
hello!printf@plt |
|
hello!sleep@plt |
0x4004e0 |
hello!getchar@plt |
0x4004f0 |
libc-2.27.so!exit |
|
动态链接使我们在调用一个共享库定义的函数可以在运行时找到函数的地址。但是在调用时编译器没办法预测这个函数(共享库定义)的运行时地址,因为定义它的共享模块可以在运行时加载到任何位置。但是GNU编译系统通过延迟绑定技术来解决这个问题,将过程地址的绑定推迟到第一次调用该过程中。
延迟绑定通过:GOT和PLT实现,如果一个目标模块调用定义在共享库中的任何函数,那么他就有自己的GOT和PLT。
第一次调用共享库函数时,不调用共享库函数,直接进入函数对应的PLT中,接着PLT指令通过对应的GOT指令进行间接跳转,由于每个GOT指令初始时都指向他对应的PLT条目的第二条指令,所以这个间接跳转只是简单的把控制传回PLT条目的下一条指令。接着把函数的ID入栈PLT跳转到PLT[0],PLT[0]再将动态链接器的一个参数入栈,然后间接跳转到动态链接器中。动态链接器依据两个栈条目确定函数的运行位置,重写对应的GOT条目,再把控制传给函数。
所以,在运行dl_init前,GOT表中存放的都是对应PLT条目的第二条指令,在运行dl_init后,GOT表中存放的就是对应的函数的地址。
这一章我们主要介绍了链接的概念与作用,又使用ld进行链接,分析了hello的格式,节头表,各段等信息。发现偏移量与进程的虚拟地址空间各段位置一一对应。比较hello与hello.o反汇编的不同处,发现共享库函数的地址变为了实际地址,又寻找了hello从头到尾的运行的函数。最后分析了hello的动态链接终于找到了共享库函数使用延迟绑定的方法,利用PLT和GOT帮助最终找到函数的地址。
第6章 hello进程管理
概念:进程是一个执行中的程序的实例,系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用:进程提供给应用程序两个关键抽象:
1、逻辑控制流
a) 每个程序似乎独占地使用CPU
b) 通过OS内核的上下文切换机制提供
2、私有地址空间
a) 每个程序似乎独占地使用内存系统
b) OS内核的虚拟内存机制提供
实际上Shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的Shell程序与其他应用程序具有同样的效果
Shell处理流程:
1.打印提示信息
2.等待用户输入
3.接受命令
4.解释命令
5.找到该命令,执行命令,如果命令含有参数,输入的命令解释它
6.执行完成,返回第一步
根据shell的处理流程,输入命令执行当前目录下的可执行文件hello,父进程会通过fork函数创建一个新的运行的子进程Hello。Hello进程几乎但不完全与父进程相同,Hello进程得到与父进程用户级虚拟空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库、以及用户栈。Hello进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,Hello进程可以读写父进程中打开的任何文件。父进程和Hello进程最大的区别在于它们有不同的PID。
fork函数只被调用一次,却会返回两次。在父进程中,fork返回Hello进程的PID,在Hello进程中,fork返回0 。
下面通过进程图来说明fork进程创建过程:
图6.2 fork进程图
在shell创建的子进程中将会调用execve函数,来调用加载器,加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载。每个程序都有一个运行时内存映像,如图6.2所示。当加载器运行时,它创建类似图6.2所示的内存映像。在程序头部表的引导下,加载器将可执行文件的片(chunk)复制到代码段和数据段。接下来,加载器跳转到程序的入口点,也就是_start函数的地址。这个函数是在系统目标文件ctrl.o中定义的,对所哟额C程序都是一样的。_start函数调用系统启动函数_ _libc_start_main,该函数定义在libc.so中,它初始化执行环境,调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。
逻辑控制流:即使在系统中通常有许多其他程序正在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果使用调试器单步调试执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,简称逻辑流。
图6.3 逻辑控制流,每个竖直的条表示一个进程的逻辑控制流的一部分
并发流:系统为每个程序都提供了一种只有它一个程序在运行的假象,但是实际情况却不是这样的,系统中很有很多其他程序在运行,比如我现在打字的word和我的虚拟机就是两个程序,它们都在运行。那么处理器是如何执行它们的,以至于让它们看起来都在不间断的一直运行呢?答案就是并发,如图6.3,处理器分时间段执行进程A、B、C,这个转换的时间非常短,所以看起来就好像每个进程都在持续不断的在运行。多个逻辑控制流并发执行的一半现象被称为并发。一个进程和其他进程轮流运行的概念成为多任务,一个进程执行它的控制流的每一时间段就成为时间片。如图6.3中进程A就由两个时间片组成。
内核模式和用户模式:内核模式和用户模式不是两个进程,而是一个进程的不同模式,由一个模式位来控制,当设置了模式位时,进程就运行在内核模式中,这时候这个进程就可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。运行程序代码的进程一开始是处于用户模式,只有当发生中断、故障或者陷入系统调用这样的异常时,转而去执行异常处理程序,这时进程才会变为内核模式。当它返回到应用程序代码时,处理器就把模式从内核模式改为用户模式。
上下文信息:内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
上下文切换:内核为每个进程维持一个上下文,上下文就是在进程执行的某些时刻,内核可以决定枪战当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度。系统调用和中断也可能引发上下文切换。
有了上面这些知识,我们来看hello的进程执行:
shell为hello fork了一个子进程,这个子进程和shell进程有独立的逻辑控制流,它们是并发进行的,但是若hello是以前台任务进行的,那么shell将会挂起等待hello运行结束,否则它们将会“同时运行”。
内核调度hello的进程开始进行,输出Hello 1170301004 wanghang,然后执行sleep(sleepsecs)函数,这个函数是系统调用,它显示地请求让调用进程休眠。内核转而执行其他进程,这时就会发生一个上下文转换。2s后(sleepsecs被隐式类型转换为2),又会发生一次进程转换,恢复hello进程的上下文,继续执行hello进程。如图6.4,其余9次sleep的执行类似。
循环结束后,后面执行到getchar函数,getchar函数是通过read函数实现的。这时hello进程将会因为执行系统调用read而陷入内核,内核中的陷阱处理程序请求DMA传输,这时读取数据一般需要很长的时间,所以将会发生一个上下文切换转而执行其他进程,当数据已经被读取到缓存区中,将会发生一个中断,使内核发生上下文切换,重新执行hello进程,与调用sleep时的进程执行情况类似。
图6.4 hello进程执行
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
1.hello正常执行时:
在调用sleep和getchar时将会出现陷阱异常,内核会调度其他进程运行,之后会重新执行hello进程。程序会依次输出10条Hello 1170301004 wanghang,最后等待用户输入,当用户输入结束后,hello进程结束发送SIGCHLD信号,然后被shell回收掉。如下图所示:
图6.5 hello正常执行时
2运行途中乱按键盘
这时运行情况和前面的相同,不同之处就在于,shell将用户乱输入的字符除了第一个回车按下之前的字符当做getchar的输入之外,其余都当做新的shell命令,在hello进程结束被回收之后,将会解释这些命令。
图6.6 hello运行途中乱按键盘
3.使用ctrl+z:
在hello运行过程中按下ctrl+z将会发送一个SIGTSTP信号给shell,然后shell将转发给当前执行的前台进程组,使hello进程挂起。使用ps命令查看,发现hello进程依旧存在,之后使用fg命令使其继续运行。Hello进程继续输出字符串,在等待用户输入阶段,我们继续使用ctrl+z使其停止,然后使用kill命令发送SIGKILL函数给hello进程。hello进程接收到SIGKILL信号后,当内核重新调度hello进程,hello进程从内核模式转为用户模式之前,先会检查hello的未被阻塞的待处理信号的集合,然后执行相应的信号处理程序,此时hello程序将会终止。进而发送SIGCHLD信号被shell回收。
一个系统中有成百上千个程序在同时运行,那么如何管理它们,让它们既能互不影响的运行,又能在必要的时候进行通信就是一个很重要的问题。一个系统要能够有效的运行,它必须建立一个简单但有效的模型。计算机系统为了解决这个问题,提供了两个抽象:进程让每个程序都以为只有它自己在运行,虚拟内存让每个程序都以为它自己在独占整个内存空间。这两个抽象使得计算机系统能够对每个程序都够以一致的方式去管理。多任务就通过进程之间快速的切换来实现,程序之间的影响就通过进程之间的通信——信号来实现。
物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。 CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。
逻辑地址:程序代码经过编译后出现在 汇编程序中地址。逻辑地址由选择符(在实模式下是描述符,在保护模式下是用来选择描述符的选择符)和偏移量(偏移部分)组成。
线性地址:逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式。分页机制中线性地址作为输入。
最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。
分段功能在实模式和保护模式下有所不同。
实模式,即不设防,也就是说逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。
在保护模式下,线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。段寄存器无法放下32位段基址,所以它们被称作选择符,用于引用段描述符表中的表项来获得描述符。描述符表中的一个条目描述一个段,构造如下:
图7.2 段描述符表中的一个条目的构造
从左开始,13位是索引(或者称为段号),通过这个索引,可以定位到段描述符(segment descriptor),而段描述符是可以真正记载了有关一个段的位置和大小信息, 以及访问控制的状态信息。段描述符一般由8个字节组成。由于8B较大,而Intel为了保持向后兼容,将段寄存器仍然规定为16-bit(尽管每个段寄存器事实上有一个64-bit长的不可见部分,但对于程序员来说,段寄存器就是16-bit的),那么很明显,我们无法通过16-bit长度的段寄存器来直接引用64-bit的段描述符。因此在逻辑地址中,只用13bit记录其索引。而真正的段描述符,被放于数组之中。
这个内存中的数组就叫做GDT(Global Descriptor Table,全局描述表),Intel的设计者门提供了一个寄存器GDTR用来存放GDT的入口地址。程序员将GDT设定在内存中某个位置之后,可以通过LGDT指令将GDT的入口地址装入此寄存器,从此以后,CPU就根据此寄存器中的内容作为GDT的入口来访问GDT了。除了GDT之外,还有LDT(Local Descriptor Table,本地描述表),但与GDT不同的是,LDT在系统中可以存在多个,每个进程可以拥有自己的LDT。LDT的内存地址在LDTR寄存器中。
找到段描述符之后,加上偏移量,便是线性地址
线性地址的表示方式是:前部分是虚拟页号后部分是虚拟页偏移。
图7.3 虚拟地址的表示
CPU通过将逻辑地址转换为虚拟地址来访问主存,这个虚拟地址在访问主存前必须先转换成适当的物理地址。CPU芯片上叫做内存管理单元(MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址。然后CPU会通过这个物理地址来访问物理内存。
页表结构:在物理内存中存放着一个叫做页表的数据结构,页表将虚拟页映射到物理页,每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。
页表就是一个页表条目(PTE)数组,虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个PTE。PTE是由一个有效位和一个n个字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。
MMU利用虚拟页号(VPN)来在虚拟页表中选择合适的PTE,当找到合适的PTE之后,PTE中的物理页号(PPN)和虚拟页偏移量(VPO)就会组合形成物理地址。其中VPO与PPO相同,因为虚拟页大小和物理页大小相同,所需要的偏移量位数也就相同。此时,物理地址就通过物理页号先找到对应的物理页,然后再根据物理页偏移找到具体的字节。
图7-5 四级页表翻译
图7-5给出了Core i7 MMU如何使用四级页表来将虚拟地址翻译成物理地址。36位的虚拟地址被分割成4个9位的片。CR3寄存器包含L1页表的物理地址。VPN1有一个到L1 PTE的偏移量,找到这个PTE以后又会包含到L2页表的基础地址;VPN2包含一个到L2PTE的偏移量,找到这个PTE以后又会包含到L3页表的基础地址;VPN3包含一个到L3PTE的偏移量,找到这个PTE以后又会包含到L4页表的基础地址;VPN4包含一个到L4PTE的偏移量,找到这个PTE以后就是相应的PPN(物理页号)。
Core i7 MMU使用四级页表来将虚拟地址翻译成物理地址,我们得到了物理地址PA。现在分析三级cache支持下的物理内存访问。如图7-6,以L1 d-cache的介绍为例,L2和L3同理。
L1 Cache是8路64组相联。块大小为64B。因此CO和CI都是6位,CT是40位。根据物理地址(PA),首先使用CI组索引,每组8路,分别匹配标记CT。如果匹配成功且块的有效位是1,则命中,根据块偏移CO返回数据。
如果没有匹配成功或者匹配成功但是标志位是1,则不命中,向下一级缓存中取出被请求的块,然后将新的块存储在组索引指示的组中的一个高速缓存行中。一般而言,如果映射到的组内有空闲块,则直接放置,否则必须驱逐出一个现存的块,一般采用最近最少被使用策略LRU进行替换。
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID 。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
exceve函数加载和执行程序Hello,需要以下几个步骤:
1.删除已存在的用户区域。
2.映射私有区域。为Hello的代码、数据、bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时复制的。
3.映射共享区域。比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页(page fault) 。图7-8展示了在缺页之前我们的示例页表的状态。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。
缺页处理程序从磁盘上用VP3的副本取代VP4,在缺页处理程序重新启动导致缺页的指令之后,该指令将从内存中正常地读取字,而不会再产生异常。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(如图7-9)。分配器将堆视为一组不同大小的块的集合,来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
1.显式分配器:要求应用显式地释放任何已分配的块。例如C程序通过调用malloc函数来分配一个块,通过调用free函数来释放一个块。其中malloc采用的总体策略是:先系统调用sbrk一次,会得到一段较大的并且是连续的空间。进程把系统内核分配给自己的这段空间留着慢慢用。之后调用malloc时就从这段空间中分配,free回收时就再还回来(而不是还给系统内核)。只有当这段空间全部被分配掉时还不够用时,才再次系统调用sbrk。当然,这一次调用sbrk后内核分配给进程的空间和刚才的那块空间一般不会是相邻的。
2.隐式分配器:也叫做垃圾收集器,例如,诸如Lisp、ML、以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
隐式空闲链表:
这样的一种结构,主要是由三部分组成:头部、有效载荷、填充(可选);
头部:是由块大小+标志位(a已分配/f空闲);有效载荷:实际的数据
图7-10 隐式空闲链表示例图
简单的放置策略:
1> 首次适配:从头搜索,遇到第一个合适的块就停止;
2> 下次适配:从头搜索,遇到下一个合适的块停止;
3> 最佳适配:全部搜索,选择合适的块停止。
分割空闲块:
适配到合适的空闲块,分配器将空闲块分割成两个部分,一个是分配块,一个是新的空闲块。
增加堆的空间:
通过调用sbrk函数,申请额外的存储器空间,插入到空闲链表中 。
合并:
(1)合并时间:立即合并和推迟合并。
立即合并:在每次一个块被释放时,就合并所有的相邻块
推迟合并:直到某个分配请求失败时,扫描整个堆,合并所有的空闲块。
(2)合并:(4种情况)
a.当前块前后的块都为已分配块:不需要合并
b.当前块后面的块为空闲块:用当前块和后面块的大小的和来更新当前块的头部和后面块的脚部。
c.当前块前面的块为空闲块:用当前块和前面块的大小的和来更新前面块 的头部和当前块的脚部。
d.当前块的前后块都为空闲块:用三个块大小的和来更新前面块的头部和 后面块的脚部。
其中,查询前面块的块大小时可以通过脚部来查,查询后面块的块大小时 可以通过头部来查。
虚拟内存使用硬盘的一部分来模拟主存,主存就是虚拟内存的一个cache,CPU寻址的时候首先会访问虚拟内存(使用页表来翻译成物理地址),若虚拟内存中的这个内容没有被缓存,那么就会触发一个缺页故障(miss),主存就会将这个内容从硬盘中拿出来,放到主存上。然后CPU重新访存,这就相当于CPU一直在CPU在主存中“取东西”(数据和指令),所以相当于虚拟内存也成了主存。为了更快的速度,CPU需要在主存上取东西的时候,也不是直接访问主存,而是先查看cache(查询页表先查询TLB,再查询cache)中有没有它想要的东西。由于局部性原理,这样使得CPU访存更快了。要执行一个新程序时,首先给新程序创建一个新进程(fork),创建新进程即就是复制一份父进程的各种的数据结构来表示它,这些数据结构中有可以表示虚拟内存的mm_struct、区域结构和页表等。加载可执行程序(execve),就是把在硬盘上的可执行文件的各个段映射到新进程的虚拟内存的各个段中,当CPU要执行的时候发现它还没有被缓存到主存上(缺页故障),此时就会将硬盘上的可执行文件复制到主存中,复制时是按页的大小来复制,需要哪一页就复制哪一页。动态分配就是将虚拟内存中的堆映射到一个匿名文件,当程序第一次要使用这个内存时,内核就会在主存中找到一个合适的牺牲页面,将其复制到硬盘中,将这块区域作为程序申请到的内存。
设备的模型化:文件
文件的类型:
设备管理:unix io接口
8.2.1打开和关闭文件:
int open(char *filename, int flags, mode_t mode);
open函数将filename转换为一个文件描述符,并返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
int close(int fd);
进程通过调用close关闭一个打开的文件。
8.2.2读和写文件
ssize_t read(int fd, void *buf, size_t n);
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
ssize_t write(int fd, const void *buf, size_t n);
write函数从内存位置buf复制之多n个字节到描述符fd的当前文件位置。
DIO *opendir(const char *name);
函数opendir以路径名为参数,返回指向目录流的指针。流是对条目有序列表的抽象,在这里是指目录项的列表。
struct dirent *readdir(DIR *dirp);
每次对readdir的调用返回的都是指向流dirp中下一个目录项的指针,或者,如果没有更过目录项则返回NULL。
int closedir(DIR *dirp);
函数closedir关闭流并释放其所有的资源。
8.2.3I/O重定向
int dup2(int oldfd, int newfd);
dup2函数复制描述符表表项oldfd到描述符表项newfd,覆盖描述符表表项newfd以前的内容。如果newfd已经打开了,dup2会在复制oldfd之前关闭newfd。
查看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;
}
首先arg获得第二个不定长参数,即输出的时候格式化串对应的值。
查看vsprintf代码:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p = buf; *fmt; fmt++)
{
if (*fmt != '%') //忽略无关字符
{
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt)
{
case 'x': //只处理%x一种情况
itoa(tmp, *((int*)p_next_arg)); //将输入参数值转化为字符串保存在tmp
strcpy(p, tmp); //将tmp字符串复制到p处
p_next_arg += 4; //下一个参数值地址
p += strlen(tmp); //放下一个参数值的地址
break;
case 's':
break;
default:
break;
}
}
return (p - buf); //返回最后生成的字符串的长度
}
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall,查看syscall的实现:
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将字符串中的字节“Hello 116220319 ouzijian”从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是我们的打印字符串“Hello 1162620319 ouzijina”就显示在了屏幕上.
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。
本章主要介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数。
hello的一生大事记如下:
编写,通过editor将代码键入hello.c
预处理,将hello.c调用的所有外部的库展开合并到一个hello.i文件中
编译,将hello.i编译成为汇编文件hello.s
汇编,将hello.s会变成为可重定位目标文件hello.o
链接,将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello
运行:在shell中输入./hello 1170300825 lidaxin
创建子进程:shell进程调用fork为其创建子进程
运行程序:shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数。
执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流
访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存。
信号:如果运行途中键入ctr-c ctr-z则调用shell的信号处理函数分别停止、挂起。
结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。
感想:感受到了计算机系统的复杂。通过一个简单的hello程序,深入理解了计算机系统。
附件
中间结果文件 |
文件作用 |
使用时期 |
Hello1.i |
预处理得到的文件 ASCII码的中间文件 |
第二章-预处理 |
Hello1.s |
ASCII汇编语言文件 |
第三章-编译 |
Hello1.o |
as得到可重定位目标文件 |
第四章-汇编 |
Hello1.asm |
反汇编得到的文本文件 |
第四章-汇编 |
Hello1.elf |
hello.o的elf文件 |
第四章-汇编 |
hello |
ld得到可执行目标文件 |
第五章-链接 |
hello_elf |
hello的elf文件 |
第五章-链接 |
hello_asm |
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.