题 目 程序人生-Hello’s P2P
专 业 计算机
学 号 xxxx
班 级 1936603
学 生 xxx
指 导 教 师 刘宏伟
本文借助hello计算机系统的底层知识介绍了程序加工、过程执行、存储管理等。主要采用实验验证的方法Ubuntu操作系统 x64cpu上分解hello在程序运行的各个阶段,从新生到结束展示一个小生命的过程,最终达到对计算机系统有深刻理解的目的。它能使读者在日常编程中提高系统能力,而不仅仅是数据结构和算法。
**关键词:**计算机系统;Linux;程序运行;流程管理;存储管理;
** **
第1章 概述 - 5 -
1.1 Hello简介 - 5 -
1.2 环境与工具 - 5 -
1.3 中间结果 - 5 -
1.4 本章小结 - 6 -
第2章 预处理 - 7 -
2.1 预处理的概念和作用 - 7 -
2.2在Ubuntu下一个预处理命令 - 7 -
2.3 Hello预处理结果分析 - 8 -
2.4 本章小结 - 10 -
第3章 编译 - 11 -
3.1 概念与作用的编译 - 11 -
3.2 在Ubuntu下面编译的命令 - 11 -
3.3 Hello分析编译结果 - 12 -
3.3.1全局变量 - 12 -
3.3.2 main函数的参数 - 12 -
3.3.3局部变量 - 13 -
3.3.4字符串常量 - 13 -
3.3.5 比较操作 - 13 -
3.3.6 分支语句 - 13 -
3.3.7循环语句 - 14 -
3.3.8数组操作 - 14 -
3.3.9函数调用 - 15 -
3.3.10函数返回 - 16 -
3.4 本章小结 - 16 -
4.1 汇编的概念和功能 - 17 -
4.2 在Ubuntu下面汇编的命令 - 17 -
4.3 可重定位目标elf格式 - 17 -
4.3.1 ELF头 - 17 -
4.3.2 节 - 18 -
4.3.3 节头表 - 19 -
4.4 Hello.o的结果解析 - 21 -
4.5 本章小结 - 24 -
第5章 链接 - 25 -
5.1 链接的概念和功能 - 25 -
5.2 在Ubuntu下链接的命令 - 25 -
5.3 可执行目标文件hello的格式 - 25 -
5.4 hello虚拟地址空间 - 28 -
5.5 链接重定位过程分析 - 30 -
5.6 hello的执行流程 - 32 -
5.7 Hello动态链接分析 - 34 -
5.8 本章小结 - 35 -
第6章 hello进程管理 - 36 -
6.1 过程的概念和作用 - 36 -
6.2 简述壳Shell-bash作用及处理过程 - 36 -
6.3 Hello的fork创建过程的过程 - 37 -
6.4 Hello的execve过程 - 38 -
6.5 Hello的进程执行 - 39 -
6.6 hello异常和信号处理 - 40 -
6.7本章小结 - 44 -
第7章 hello的存储管理 - 45 -
7.1 hello存储地址空间 - 45 -
7.1.1逻辑地址 - 45 -
7.1.2线性地址 - 45 -
7.1.3虚拟地址 - 45 -
7.1.4物理地址 - 45 -
7.1.5 hello的地址空间 - 45 -
7.2 Intel从逻辑地址到线性地址的转换-段管理 - 46 -
7.3 Hello从线性地址到物理地址的转换-页面管理 - 46 -
7.4 TLB支持四级页面VA到PA的变换 - 47 -
7.5 三级Cache支持的物理内存访问 - 49 -
7.6 hello进程fork时间内存映射 - 50 -
7.7 hello进程execve时间内存映射 - 51 -
7.8 缺页故障和缺页中断处理 - 52 -
7.9动态存储分配管理 - 52 -
7.10本章小结 - 54 -
第8章 hello的IO管理 - 55 -
8.1 Linux的IO设备管理方法 - 55 -
8.2 简述Unix IO接口及其函数 - 56 -
8.2.1打开和关闭文件 - 56 -
8.2.2 读和写文件 - 56 -
8.3 printf的实现分析 - 57 -
8.4 getchar的实现分析 - 59 -
8.5本章小结 - 59 -
结论 - 60 -
附件 - 64 -
参考文献 - 65 -
第1章 概述
1.1 Hello简介
本论文会从一个hello程序的一生作为示例,分析一个C语言程序在linux+
X64环境下所要经历的全部内容。
首先从编写高级语言源程序开始,我们需要建立一个hello.c文件,并按照特定的语法写入程序,但是此时的程序还不能执行,我们需要经过预处理(展开头文件、宏定义等)、编译(翻译为汇编语言程序)、汇编(翻译为可重定位的目标程序)、链接(生成可执行的目标程序),最终生成一个可以执行的程序hello。
接着,这个程序要想运行,需要在shell中输入命令./hello 1190401018 黄子扬,shell在磁盘上找到相应的程序,现fork一个子进程,接着用execve将hello的上下文加载入这个子进程,并开始执行。程序执行结束后,它并不会立刻完全消失,需要等待父进程将其回收,至此,它才能完全从世界上消失。
在控制流执行的过程中,离不开硬件的帮忙,而现代的处理器的流水线设计,大大提高了吞吐率,使得不同程序的取值、译码、执行、访存、写回、更新PC六大阶段可以在一定条件下并行,极大的提高了运行速率。
程序当然是需要存储空间的了,当前的系统具有四级页表、三级cache、还有容量超大的主存和磁盘,如此复杂却又精细的一套内存管理系统,让hello能够尽情的运行在计算机系统上。cpu从虚拟地址开始,首先查找TLB,如果直接找到则能直接翻译为物理地址,否则则按照级别从多级页表中查找,最终翻译为物理地址,接着又到L1cache中去寻找我们要的字节数据,如果没找到,则按照存储体系从上到下继续寻找。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
软件环境:Windows10 64位; Vmware 15.6;Ubuntu 20.04 LTS 64位
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk
开发与调试工具:gcc、gdb、vscode、readelf、edb
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
附件1 hello.c
作用:高级语言源程序
附件2 hello.i
作用:预处理生成的.i文件
附件3 hello.s
作用:编译生成的.s文件(汇编语言文件)
附件4 hello.o
作用:汇编生成的.o文件(可重定位的目标文件)
附件5 hello.elf
作用:hello.o的elf格式,用于展示可重定位的elf文件格式
附件6 hello.asm
作用:hello.o的反汇编格式,用汇编语言的格式来观察可重定位的目标文件
附件7 hello
作用:链接生成的可执行的目标文件
附件8 hello_exe.elf
作用:hello的elf格式,用于展示可执行的elf文件格式
附件9 hello_exe.asm
作用:hello的反汇编格式,用汇编语言的格式来观察可执行的目标文件
1.4 本章小结
本章从总体上介绍了hello程序的一生,主要从p2p的过程,即从高级语言源程序到正在执行的进程的过程,和020的过程,即从程序还没开始执行到最终进程被回收的过程,这两个过程来展开的。
本章的精华涵盖了一个程序执行的几乎所有方面:预处理、编译、汇编、链接、fork子进程、execve切换上下文、进程信号机制、硬件执行流程(F、D、E、M、W、U)、从cache到磁盘的存储体系、进程回收机制等。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。通常由以下几个操作组成:
头文件展开:将#include包含的文件插入到该指令位置
宏展开:展开所有的宏定义,并删除#define
条件编译:处理所有的条件预编译指令: #if、 #ifdef、 #else 删除注释
添加行号和文件名标识:编译调试时显示行号信息
保留#pragma命令
作用:
宏定义可以简化我们的编程,赋予一个常量有意义的特定的名字,在编程中可以方便程序员的操作,在预处理后会统一进行替换;
头文件的包含可以帮助程序员调用其他模块定义的函数、功能,方便将一个大型程序进行系统化、模块化、组件化;
注释只是在源文件中帮助程序员理解而写的文字,完全没必要在编译的时候考虑,所以一律删掉;
条件编译可以帮助程序员有选择地进行编译,针对不同情况进行处理;
添加行号信息则是方便程序员进行调试操作,能够在debug的时候快速定位错误位置。
2.2在Ubuntu下预处理的命令
gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i
图2-2-1 预处理命令
图2-2-2 预处理后得到的文件hello.i
2.3 Hello的预处理结果解析
我们打开预处理得到的hello.i文件可以发现,这其中有着大量的头文件展开后引入的内容,可以验证预处理其中的一个最大的作用就是解析头文件的引用,进而确定到具体的代码的位置。如图2-3-1所示。
图2-3-1 hello.i中的头文件引入的内容
可以发现,在hello.i文件中,定义了大量的数据类型的别名。应该都是从系统库中引入的一些东西。如图2-2-3所示。
图2-3-2 定义数据别名
我们可以发现,从一个简单到只有不到30行的程序,经过预处理后竟然有长达3000余行的代码。如图2-3-3。
图2-3-3 预处理后源程序
2.4 本章小结
本章简要介绍了c语言程序预处理的基本知识,从预处理指令到预处理后的文件的改变,我们可以看到程序的生成其实是很困难的,远不是我们表面看上去的那么简单。其中比较重要的是头文件和宏定义的展开。
第3章 编译
3.1 编译的概念与作用
概念:
编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序,即编译的目的是将高级语言源程序转化为汇编语言源程序,但本质上这两者都还是机器看不懂的程序,都是为了方便人能看懂,只是级别不太一样。汇编语言程序更加贴近底层,能够直接观察到代码操纵了哪些数据、哪些内存、哪些寄存器、发生了怎样的转换、控制流是如何传递和跳转的等等。
作用:1.扫描(词法分析),2.语法分析,3.语义分析,4.源代码优化(中间语言生成),5.代码生成,目标代码优化。
1.将源代码程序输入扫描器,将源代码的字符序列分割成一系列记号。
2.基于词法分析得到的一系列记号,生成语法树。
3.由语义分析器完成,指示判断是否合法,并不判断对错。又分静态语义:隐含浮点型到整形的转换,会报warning;动态语义:在运行时才能确定。
4.中间代码(语言)使得编译器分为前端和后端,前端产生与机器(或环境)无关的中间代码,编译器的后端将中间代码转换为目标机器代码,目的:一个前端对多个后端,适应不同平台。
5.编译器后端主要包括:
代码生成器:依赖于目标机器,依赖目标机器的不同字长,寄存器,数据类型等;目标代码优化器:选择合适的寻址方式,左移右移代替乘除,删除多余指令。
3.2 在Ubuntu下编译的命令
gcc -m64 -no-pie fno-PIC -S hello.i -o hello.s
图3-2-1 编译命令
图3-2-2 编译后得到的文件hello.s
3.3 Hello的编译结果解析
3.3.1全局变量
我们直到在C语言源程序中定义了int sleepsecs=2.5,这是一个已经初始化了的全局变量,我们在hello.s中观察,可以发现它是在.rodata节中的。
图 3-3-1 全局变量sleepsecs
3.3.2 main函数的参数
由寄存器相关的知识,我们知道存储函数参数的前六个寄存器分别是%rdi,%rsi,%rdx,%rcs,%r8,%r9。根据汇编代码,我们可以发现argc和argv分别存储在%edi,%rsi中,并在一开始首先分别保存到了-20(%rbp),-32(%rbp)的位置。
图3-3-2 寄存器传递函数参数
3.3.3局部变量
我们知道局部变量一般直接存储在栈中。我们知道本题的循环变量i是一个局部变量,观察发现它若能执行到循环分支,则它被初始化为0,且存储在栈中。
图3-3-3 局部变量存储在寄存器中
3.3.4字符串常量
字符串常量一般存储在.text节中。我们在汇编代码中可以发现,我们用到的两个字符串常量均在.text节中存储,并且各自有一个标号。
图3-3-4-1 字符串常量的存储
我们还可以发现在使用字符串常量的时候,是直接使用标号来引用的。
图3-3-4-2 通过标号来引用字符串常量
3.3.5 比较操作
变量之间的不等关系是通过!=符号来实现的。而在汇编代码中的体现则是用cmp语句。
图3-3-5 cmp语句的使用来比较两个变量的大小关系
3.3.6 分支语句
分支语句是基于3.3.5中的比较语句加jxx跳转语句实现的,通过cmp设置的标志,达到一定条件则执行对应的分支。
图3-3-6 分支的实现
3.3.7循环语句
循环也是通过比较加跳转语句来实现的。
初始值是0,和9作比较,总循环次数10次
图3-3-7 循环的实现
3.3.8数组操作
我们知道数组的操作一般都是通过首地址加上偏移量得到的,我们在汇编代码中可以观察到这种方式用在了取argv中的字符串的地址。
argv数组中的内容存储在了栈中,我们从中取出对应的字符串的地址,并分别放到%rsi和%rdx中,作为printf的第二和第三个参数,最终输出到了屏幕上。
图3-3-8 数组操作
3.3.9函数调用
函数调用在汇编中的实现很简单,就是调用call指令。在hello.s中执行多次。而函数参数的传递则类似于3.3.2中的描述。前六个参数分别存储于%rdi,%rsi,%rdx,%rcx,%r8,%r9,而剩余的参数则存放在栈中,位于返回地址的上面。
图3-3-9-1 调用puts
图3-3-9-2 调用printf
图3-3-9-3 调用sleep
3.3.10函数返回
一般的函数返回前会有这样几个操作,恢复被调用者保存的寄存器的值,恢复旧的帧指针%rbp(不一定有这个操作),并跳转到原来的控制流的地址。最终一般都是以ret指令结尾的。
图3-3-10 ret指令
3.4 本章小结
本章的核心是汇编代码的深入理解。从核心知识上来说有这样几点:数据操作、算数运算、逻辑运算、流程控制、过程调用等等。我们从一个具体的汇编代码文件hello.s来总结整个汇编方面我们需要掌握的知识。
主要涉及到的有:全局变量、本地局部变量、字符串常量、分支语句、循环语句、比较操作、数组、函数调用、函数参数传递、函数返回。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器将hello.s翻译成机器语言指令,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,是机器可以读懂的语言。在我们程序员的的角度来看,目标文件中就完全是乱码。
作用:将汇编语言翻译成可重定位的二进制目标文件。
4.2 在Ubuntu下汇编的命令
图4-2-1 汇编指令
图4-2-2 汇编生成的hello.o文件
4.3 可重定位目标elf格式
4.3.1 ELF头
图4-3-1 ELF头
上图是hello.o的ELF可重定位目标文件的格式。
ELF头以一个16字节的序列开始,这个序列描述生成了该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定的大小的条目。
具体代码格式如下:
#define EI_NIDENT 16
typedef struct{
unsigned char e_ident[EI_NIDENT]; //包含用以表示ELF文件的字符,以及其他一些与机器无关的信息。开头的4个字节值固定不变,为0x7f和ELF三个字符。
Elf32_Half e_type;// 标识的是该文件的类型
Elf32_Half e_machine;// 表明运行该程序需要的体系结构
Elf32_Word e_version;// 表示文件的版本
Elf32_Addr e_entry;// 程序的入口地址
Elf32_Off e_phoff;// 表示Program header table 在文件中的偏移量
Elf32_Off e_shoff;// 表示Section header table 在文件中的偏移量
Elf32_Word e_flags;// 对IA32而言,此项为0
Elf32_Half e_ehsize;// 表示ELF header大小
Elf32_Half e_phentsize;// 表示Program header table中每一个条目的大小
Elf32_Half e_phnum;// 表示Program header table中有多少个条目
Elf32_Half e_shentsize;// 表示Section header table中的每一个条目的大小
Elf32_Half e_shnum;// 表示Section header table中有多少个条目
Elf32_Half e_shstrndx;// 包含节名称的字符串是第几个节
} Elf32_Ehdr;
4.3.2 节
在ELF头和节头表之间,存储的是各个节的内容。我们可以使用readelf -p num <file>的命令来查看节的内容:
图4-3-2 某几个节的内容
4.3.3 节头表
节头表通常包含以下几个节:
.text |
---|
.rodata |
.data |
.bss |
.symtab |
.rel.text |
.rel.data |
.debug |
.line |
.strtab |
图4-3-3-1 典型的节头表格式
.text:已编译程序的机器代码
.rodata:只读数据,比如printf语句中的格式串和开关语句中的跳转表
.data:已初始化的全局和静态C变量。局部局部C变量在运行的时候保存在栈中,既不出现在.data节中,也不出现在.bss节中。
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态C变量。在目标文件中这个节不占用实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。
.symtab:一个符号表,它存放在程序中定义和引用的的函数和全局变量的信息。一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在.symtab中都有一张符号表。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
.rel.text:;一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件并不需要重定位信息,因此通常忽略,除非用户显示地指示链接器包含这些信息。
.rel.data:被模块引用或者定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都要被修改。
.debug:一个调试符号表,其条目时程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才会得到这张表。
.line:原始C程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会得到这张表。
.strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串的序列。
图 4-3-3-2 hello.o的节头表
4.4 Hello.o的结果解析
我们将反汇编的结果输入到文件hello.asm中。
对比分析:
1.hello.s中是没有位置信息的,但是在hello.asm是有的,代码间是有顺序关系的。
图4-4-1 hello.asm中代码是有位置信息的
图4-4-2 hello.s中的代码是没有地址的
2.hello.asm中使用地址进行跳转,而hello.s中使用标号进行跳转
图4-4-3 使用地址跳转
图4-4-4 使用标号进行跳转
3.hello.asm中使用地址进行函数调用,而hello.s中使用函数名进行跳转
图4-4-5 hello.asm中使用地址进行函数调用
图4-4-6 hello.s中使用函数名进行函数调用
4.hello.asm 中有重定位条目,而hello.s中没有
如图4-4-7中sleepsecs中的重定位格式是R_X86_64_PC32说明是PC相对的32位的地址,当前PC是main+64,减0x04后是main+60,正好是其应该填充的位置,在重定位后就会填充到这个位置。
重定位条目
图4-4-7 重定位条目
图4-4-8 hello.s中没有重定位条目
机器语言:就是机器可以直接识别执行的二进制代码,是我们人无法理解的语言,一般是由操作码和操作数构成的。比如call在机器语言中对应的编码就是e8,而操作数则是由是对应的编码的小端格式,如补码、IEEE754表示的浮点格式等等。
汇编语言:是人类为了方便编程,发明出来的和机器语言对应的编程语言,引入了助记符,帮助人们快速的对应复杂的二进制编码。
关系:汇编语言和机器语言基本上是一一对应的,每一条汇编语言实际上是一条机器语言的助记符形式。
4.5 本章小结
本章我们把汇编语言源程序汇编成了可重定位的目标文件。分析了可重定位的目标文件的elf格式,elf头、节头表、符号表等等内容,理解了汇编后的二进制可重定位代码。知道了重定位条目的信息,当重定位形成可执行文件之后,就会在对应的位置填入相应的数据。
objdump工具可以帮助我们将可重定位的目标文件反汇编生成反汇编代码,进一步理解hello.o的内容。
第5章 链接
5.1 链接的概念与作用
概念:
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存中并执行。链接可以执行于编译时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。在早期的计算机系统中,链接甚至是手动执行的。在现代系统中,链接是由叫做链接器的程序自动执行的。
作用:
链接器在软件开发中扮演着一个关键的角色,因为他们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需要简单地重新编译它,并重新链接应用,而不是重新编译其他文件。
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 链接指令
图5-2-2 链接后生成的可执行目标文件
5.3 可执行目标文件hello的格式
hello的elf格式和可重定位的目标文件的elf格式相类似。
图5-3-1 可执行文件的elf格式
其中可以很明显的发现在Type一栏变成了EXEC,即可执行的。
地址
大小
图5-3-2 节头表
5.4 hello的虚拟地址空间
首先用edb加载hello程序。
图5-4-1 使用edb加载hello
查看符号
图5-4-2 符号
可以发现,和图5-3-2中的一致,interp都在0x4002e0。其他的节都在这附近,可以直接在symbols中或者data dump中观察到。
或者可以直接输入对应节的起始地址,直接再Boot marks中查看。
图5-4-3-1 直接用boot marks查看地址内容
图5-4-3-2 不存在地址的节
可以发现,最后着5个节都没有地址,因为他们的内容已经在连接后直接加入到了二进制代码之中,不需要再次存储了,可执行程序已经可以正常执行了。
5.5 链接的重定位过程分析
链接的过程:
1.符号解析
目标文件(.o)定义和引用了符号;
每个符号对应着一个函数、一个全局变量或一个静态变量等;
其中函数、已初始化的全局变量或者静态变量是强符号,未初始化的全局变量或者静态变量是弱符号;
其中有三条链接时的规则:1.不允许由多个同名的强符号;2.如果有一个强符号和多个弱符号同名,那么选择强符号;3.如果由多个弱符号同名,那么从这些弱符号中任意选择一个;
符号解析的作用就是给每个符号引用分配一个精确的符号定义;
2重定位
编译器和汇编器生成的代码段和数据段的开始地址都是0;
链接器给每个符号定义分配一个内存地址,然后修改所有对这些符号的引用使得这些引用指向的是前面分配的内存地址;
链接器使用由汇编器生成的详细的指令(重定位条目)来执行这些重定位操作;
hello没有了hello.o重定位条目,因为所有的地方都已经确定了位置,而hello.o因为还没有链接,所以还保留有许多汇编器生成的重定位条目供之后的链接使用。链接的本质是合并不同文件的相同节,如数据节合并在一起,而代码节合并在一起。在这个过程中因为文件要使用其他文件中符号的定义,所以就需要用到在汇编过程中生成的.rel.data和.rel.text节中的重定位信息。
具体看到hello和hello.o的差别,我们通过hello反汇编生成的hello_exe.asm和hello.asm作比较来查看。
首先最明显的差别就是地址的长度不同,未链接的文件中只是简单的使用相对偏移量,而链接后的文件中则是使用虚拟地址空间中的地址。
图5-5-1 hello.asm中使用相对偏移量来作为地址
图5-5-2 hello_exe.asm中使用虚拟地址
其次,在主函数中调用的函数都不是定义子该文件中的,而是定义在库文件中的,所以未链接时在文件中找不到这些函数的定义,而链接后则可以找到。
图5-5-3 在链接后可以在反汇编代码中找到调用函数的定义
最后我们用一个可重定位条目翻译的例子来看重定位的过程:
图5-5-4 一个重定位条目的例子
我们在已链接的文件中找到对应的地方。
可以发现其绝对地址时404044,但是是使用的PC相对引用,当程序执行到401163时,PC的值时401169,在加上相对的偏移值0x2edb,即为绝对地址404044。
5.6 hello的执行流程
通过命令行输入参数执行程序
图5-6-1 用edb执行hello程序
不断地step into,可以看到执行过的程序名,如:
图5-6-2 hello!_start
执行过程:
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执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
图5-6-3 edb中的执行结果
5.7 Hello的动态链接分析
动态链接主要是书上7.10到7.12的内容。
书上的解释是这样的:假设程序调用一个有共享库定义的函数。编译器无法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法时为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU通过一种叫做延迟绑定的技术来将地址的绑定推迟到第一次调用该过程。
动态链接器使用过程链接表PLT和全局偏移量表GOT实现函数的动态链接。其中GOT 中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
通过反汇编我们可以知道GOT和PLT的位置
图5-7-1 GOT和PLT的位置
GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]时动态链接器在ld-linux.so模块种的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT项目。
图5-7-2 链接前的GOT
图5-7-3 动态链接后的GOT
5.8 本章小结
链接是现代编程思想的精华部分之一,链接器的出现,使得模块化的编程成为常态,分块编程,各自编译,最后链接是一个很自然的想法。同时,我们程序员也可以更好的使用库函数了。这得益于动态链接的出现,我们不用多次在文件中保存相同的代码,只需要用一个动态链接库,等到函数需要被调用时,再去取对应的代码就可以了。
而传统的静态链接有两个过程,首先是符号解析,需要重点知道强弱符号的规则,第二是重定位,需要知道解析引用的原则。
第6章 hello进程管理
6.1 进程的概念与作用
狭义概念:进程是正在运行的程序的实例
广义概念:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
这其中有两点最为重要的需要我们深入把握和理解:
第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。
作用:当在现代系统上运行一个程序的时候,我们会得到一个假象,就好像我们的程序时系统种当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中地指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象是通过进程的概念提供给我们的。
进程的并发性让我们能够同时运行多个程序在电脑上,而且看起来是在独占CPU的;进程异步性让我们同时运行多个程序时是互不影响的;进程的信号机制能够让不同的进程之间产生联系,让进程之间实现通信……进程是计算机科学中最伟大的概念之一,有了进程,才有我们今天的高性能PC机。
6.2 简述壳Shell-bash的作用与处理流程
概念:在计算机科学中,Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(command interpreter,命令解析器)。它类似于DOS下的COMMAND.COM和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。
CSAPP上的解释就是:shell是一个交互型的应用级程序,它代表客户运行其他程序。
功能: Shell连接了用户和Linux内核,让用户能够更加高效、安全、低成本地使用Linux内核, 能够接收用户输入的命令,并对命令进行处理,处理完毕后再将 结果反馈给用户,比如输出到显示器、写入到文件等。
在 Shell 中输入的命令,有一部分是 Shell 本身自带的,这叫做内置命令;有一部分是其它的应用程序(一个程序就是一个命令),这叫做外部命令。
Shell 本身支持的命令并不多,功能也有限,但是 Shell 可以调用其他的程序,每个程序就是一个命令,这使得 Shell 命令的数量可以无限扩展,其结果就是 Shell 的功能非常强大,完全能够胜任 Linux 的日常管理工作,如文本或字符串检索、文件的查找或创建、大规模软件的自动部署、更改系统设置、监控服务器性能、发送报警邮件、抓取网页内容、压缩文件等。
处理流程:
1.Shell首先从命令行中找出特殊字符(元字符),在将元字符翻译成间隔符号。元字符将命令行划分成小块tokens。Shell中的元字符如下所示:
SPACE , TAB , NEWLINE , & , ; , ( , ) ,< , > , |
2. 程序块tokens被处理,检查看他们是否是shell中所引用到的关键字。
3. 当程序块tokens被确定以后,shell根据aliases文件中的列表来检查命令的第一个单词。如果这个单词出现在aliases表中,执行替换操作并且处理过程回到第一步重新分割程序块tokens。
4.Shell对~符号进行替换。
5.Shell对所有前面带有$符号的变量进行替换。
6.Shell将命令行中的内嵌命令表达式替换成命令;他们一般都采用$(command)标记法。
7.Shell计算采用$(expression)标记的算术表达式。
8.Shell将命令字符串重新划分为新的块tokens。这次划分的依据是栏位分割符号,称为IFS。缺省的IFS变量包含有:SPACE , TAB 和换行符号。
9.Shell执行通配符* ? [ ]的替换。
10.shell把所有从处理的結果中用到的注释删除,並且按照下面的顺序实行命令的检查:
A.内建的命令
B. shell函数(由用户自己定义的)
C. 可执行的脚本文件(需要寻找文件和PATH路径)
11.在执行前的最后一步是初始化所有的输入输出重定向。
12.最后,执行命令。
6.3 Hello的fork进程创建过程
我们在终端输入./hello 1190401018 黄子扬,回车后即开始了这个过程
图6-3-1 进程执行的命令
此时,bash首先搜索这是否是一个内置命令,发现不是后,进程开始在磁盘上搜索,找到hello程序后,将其调入内存。
接着bash实行fork()函数,创建一个子进程,此时,该子进程拥有和父进程完全相同的虚拟地址空间副本,即是相对于父进程是独立的。如果能够在fork函数在父进程和子进程中返回后立即暂停这两个进程,我们会看到两个进程的地址空间都是相同的。每个进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。同时父进程和子进程是共享文件的。父进程和子进程的区别在于他们的进程号不同,即PID不同。
父进程和子进程在各自的虚拟地址空间内独立并发的向前推进。
简要介绍fork函数:
pid_t fork(void);
fork函数调用一次,却会返回两次:一次是在父进程中,一次是在新创建的子进程中。在父进程中,fork返回子进程的PID。在子进程中,fork返回0。因为子进程的PID总是为非0,返回值就提供了一个明确的方法来分辨程序是在父进程还是在子进程中执行。
6.4 Hello的execve过程
但是此时fork出来的子进程并没有达到要执行hello程序的目的,我们还要求换上下文,去执行hello程序。
简要介绍execve函数:
int execve(const char *filename, const char *argv[], const char *envp[]);
如果成功,则不返回,如果失败,则返-1。
execve函数加载并运行可执行目标文件filename,即hello程序,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。
其中的参数列表:argv变量指向指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0]是可执行目标文件的名字。环境变量列表类似于参数列表,envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name=value”的名字-值对。
bash得到的子程序会去执行execve函数,去加载hello程序,它会首先调用启动代码,启动代码设置栈,并将控制传递给新程序的主函数,该主函数具有如下形式的原型:
int main(int argc, char **argv, char **envp);
当main开始执行时,用户栈的组织结构从栈底往栈顶依次是这样的:首先是参数和环境字符串,栈往上紧随其后的是以null结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串,全局变量environ指向这些指针中的第一个envp[0]。紧随环境变量数组之后的是以null结尾的argv[]数组,其中每个元素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数libc_start_main的栈帧,接下来就是main函数执行后的即未来的栈帧了。
6.5 Hello的进程执行
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
进程时间片的概念:时间片(timeslice)又称为“量子(quantum)”或“处理器片(processor slice)”是分时操作系统分配给每个正在运行的进程微观上的一段CPU时间(在抢占内核中是:从进程开始运行直到被抢占的时间)。现代操作系统(如:Windows、Linux、Mac OS X等)允许同时运行多个进程 —— 例如,你可以在打开音乐播放器听音乐的同时用浏览器浏览网页并下载文件。事实上,虽然一台计算机通常可能有多个CPU,但是同一个CPU永远不可能真正地同时运行多个任务。在只考虑一个CPU的情况下,这些进程“看起来像”同时运行的,实则是轮番穿插地运行,由于时间片通常很短(在Linux上为5ms-800ms),用户不会感觉到。
进程调度:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进城后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。
图6-5-1 进程调度图示
hello程序一开始是运行在user code中的,所以是不能执行kernel里的代码的,只能通过异常的方式,使其改变为内核模式,执行异常处理程序中的系统调用代码,即sleep函数。等待执行完后,又再依次切换到用户模式执行hello程序。
进程之间的上下文切换是在kernel里完成的,完成后,又退出内核模式,进入到用户模式,去执行切换后的进程,这就是调度的原理。
6.6 hello的异常与信号处理
hello出现的异常:中断,陷阱(即系统调用)
hello产生的信号:SIGINT、SIGTSTP、SIGCONT、SIGCHLD
我们首先先执行hello程序,并在执行了一会后输入Ctrl+C,直接终止它。进程会收到一个SIGINT信号。
图6-6-1 进程收到SIGINT信号后终止
接着我们再次运行,用Ctrl+Z暂停进程。并且用ps命令查看进程,可以发现hello进程仍然存在。
图6-6-2 进程收到SIGTSTP信号后停止
还可以使用jobs进行观察。
图6-6-3 用jobs观察作业
用pstree观察进程树,我们可以在终端部分找到bash和hello进程。其中hello和pstree都是bash的子进程
可以看到hello进程是bash的子进程
图6-6-4 进程树
我们使用kill指令为其传入一个SIGCONT信号,让它继续执行下去。
图6-6-5 传入SGICONT信号让进程继续执行
我们使用kill指令为其传入一个SIGKILL信号杀死hello程序。
图6-6-6 传入SIGKILL信号将进程杀死
6.7本章小结
进程是现代计算机科学最伟大的概念之一,它让计算机有了并发执行多个程序的能力,大大提高了计算机的性能。我们通过这章的学习,了解到了进程的基本概念、运行原理、进程的用户模式和内核模式、上下文切换、两个重要函数(fork和execve)、四大异常(终端、陷阱、故障、终止)、进程的并发执行等等。
通过这章的学习也让我们对操作系统有了基本的认识,只有有了操作系统,我们才能够控制冷冰冰的硬件来为我们工作。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
CPU所生成的地址。逻辑地址是内部和编程使用的、并不唯一。例如,你在进行C语言指针编程中,可以读取指针变量本身值(&操作),实际上这个值就是逻辑地址,它是相对于你当前进程数据段的地址(偏移地址),不和绝对物理地址相干。
7.1.2线性地址
CPU在保护模式下,“段基址+段内偏移地址”叫做线性地址,注意,保护模式下段基址寄存器中存储的不是真正的段基值(和实模式的含义不一样),而是被称为“段选择子”的东西,通过段选择子在GDT(全局描述表)中找到真正的段基值。另外,如果CPU在保护模式下没有开启分页功能,则线性地址就被当做最终的物理地址来用,若开启了分页功能,则线性地址就叫虚拟地址(在没开启分页功能的情况下线性地址和虚拟地址就是一回事)。但是,如果开启分页功能,虚拟地址(或线性地址)还要通过页部件电路转换成最终的物理地址。
7.1.3虚拟地址
虚拟地址并不真实存在于计算机中。每个进程都分配有自己的虚拟空间,而且只能访问自己被分配使用的空间。理论上,虚拟空间受物理内存大小的限制,如给有4GB内存,那么虚拟地址空间的地址范围就应该是0x00000000~0xFFFFFFFF。每个进程都有自己独立的虚拟地址空间。这样每个进程都能访问自己的地址空间,这样做到了有效的隔离。
7.1.4物理地址
物理地址就是内存单元的绝对地址,比如你有一个4G的内存条插在电脑上,物理地址0x0000就表示内存条的第一个存储单元,0x0010就表示内存条的第17个存储单元,不管CPU内部怎么处理地址,最终访问的都是物理地址。在CPU实模式下“段基址+段内偏移地址”就是物理地址,CPU可以使用此地址直接访问内存。
7.1.5 hello的地址空间
hello程序主要使用的就是从虚拟地址到物理地址变换的这一套机制,来实现其虚拟地址空间的独立化,但是同时又能精确的映射到对应的物理空间,使其和其他的程序的地址空间隔离。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在段式存储管理中,将程序的地址空间划分为若干个段(segment),这样每个进程有一个二维的地址空间。在前面所介绍的动态分区分配方式中,系统为整个进程分配一个连续的内存空间。而在段式存储管理系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。
程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。在为某个段分配物理内存时,可以采用首先适配法、下次适配法、最佳适配法等方法。在回收某个段所占用的空间时,要注意将收回的空间与其相邻的空间合并。段式存储管理也需要硬件支持,实现逻辑地址到物理地址的映射。
程序通过分段划分为多个模块,如代码段、数据段、共享段:可以分别编写和编译;可以针对不同类型的段采取不同的保护;可以按段为单位来进行共享,包括通过动态链接进行代码共享。这样做的优点是:可以分别编写和编译源程序的一个文件,并且可以针对不同类型的段采取不同的保护,也可以按段为单位来进行共享。
总的来说,段式存储管理的优点是:没有内碎片,外碎片可以通过内存紧缩来消除;便于实现内存共享。缺点与页式存储管理的缺点相同,进程必须全部装入内存。
图7-2-1 段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
将程序的逻辑地址空间划分为固定大小的页(page),而物理内存划分为同样大小的页框(page frame)。程序加载时,可将任意一页放人内存中任意一个页框,这些页框不必连续,从而实现了离散分配。该方法需要CPU的硬件支持,来实现逻辑地址和物理地址之间的映射。
在页式系统中进程建立时,操作系统为进程中所有的页分配页框。当进程撤销时收回所有分配给它的页框。在程序的运行期间,如果允许进程动态地申请空间,操作系统还要为进程申请的空间分配物理页框。操作系统为了完成这些功能,必须记录系统内存中实际的页框使用情况。操作系统还要在进程切换时,正确地切换两个不同的进程地址空间到物理内存空间的映射。这就要求操作系统要记录每个进程页表的相关信息。
图7-3-1 页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
首先在TLB中查找PTE,若能直接找到则直接得到对应的PPN,具体的操作是将VPN看作TLBI和TLBT,前者是组号,后者是标记,根据TLBI去对应的组找,如果TLBT能够对应的话,则能够直接得到PTE,进而得到PPN。
图7-4-1 从TLB寻找PTE
其中若是在TLB中找不到对应的条目,则应去多级页表中查找,VPN被分为了四块。有一个叫做CR3的寄存器包含L1页表的物理地址,VPN1提供到了一个L1 PET的偏移量,这个PTE包含L2页表的基地址,VPN2提供到一个L2 PTE的偏移量。依次类推,最终找到页表中的PTE,得到PPN。
图7-4-2 四级页表的查询
而VPO和PPO相等,最终的PA等于PPN+PPO。
7.5 三级Cache支持下的物理内存访问
PA又被分为了CT、CI、CO分别是标志位、组号和偏移量。首先我们根据组号在L1cache中找到对应的组,然后挨个比较标志位,如果标志位对应且有效位为1,则说明发生了hit,然后根据CO偏移量得到我们想要取的数据就可以了。如果发生了miss,则依次到L2cache、L3cache、主存中去找。
图7-5-1 三级cache的示意图
具体的翻译过程:
CT即相当于t,CI即相当于s,CO即相当于b。根据cache的工作机制,即先到对应的组去找,然后一一匹配t,检查v,最终若找到则根据b取数据,若没有找到则到更低级的存储中去取数据。
图7-5-2 cache的具体翻译过程
7.6 hello进程fork时的内存映射
内核为hello程序维护一个单独的任务结构(源代码中的task_struct)。任务结构中的元素包含或者指向内核运行该进程所需要的所有信息(例如,PID、指向用用户栈的指针、可执行目标文件的名字、PC)。
图7-6-1 hello如何组织虚拟内存
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork从新进程返回时,新进程现在的虚拟内存刚好和调用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-7-1 加载器映射用户地址空间的区域
7.8 缺页故障与缺页中断处理
若MMU在翻译一个虚拟地址A时,触发了一个却页。这个却页将导致控制转移到内核的却页处理程序。却页处理程序会执行以下的步骤:
1.搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止进程。
2.检查这个却页是否为一条试图对这个代码段里的只读页面进行写操作的存储指令造成的,或者是否为一个运行在用户模式中的进程试图从内核虚拟内存中读取数据字造成的。如果试图进行的访问时不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。
3.若上面两条都不是触发缺页的理由,则此时内核知道了这个缺页是由于对合法的的虚拟地址进行合法的操作造成的。内核会选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU,此时,MMU就能正常的翻译A了。
图7-8-1 缺页处理
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,指向堆的顶部。
分配器有两种风格,但是这两种风格都要求应用显示的分配块。
其中显示分配器要求显示释放任何已分配的块,如malloc、new等。
隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。所以也叫垃圾收集器。
显示分配器有以下几点要求:能够处理任意请求序列;立即相应请求;只是用堆;对齐开;不修改已分配的块。
在性能上有两点追求:
1.最大化吞吐率;
2.最大化内存利用率;
但是这两点通常是对立的。
具体的技术有以下几种:
隐式空闲列表
块大小(最低位保存a/f) |
---|
有效载荷 (只包括已分配的块) |
填充(可选,为了保持双字对齐) |
块大小(最低位保存a/f) |
显示空闲列表
图7-9-1 显示空闲列表图示