本文主要介绍了hello程序的整个生命周期。hello程序从hello.c经过预处理生成hello.i文件、经过编译生成汇编语言文件hello.s、经过汇编生成可重定位目标文件hello.o、最后经过链接生成可执行文件hello,由存储器保存在磁盘中。在这期间我们分析了这些文件的区别与照应之处。在shell接收到./hello的指令之后,开始调用fork函数创建进程,运行进程时,操作系统为其分配虚拟地址空间,提供异常控制流等,Unix I/O为其提供与程序员和系统文件交互的方式,最后结束进程并由父进程进行回收,hello走向“生命”的尽头。
关键词:计算机系统;预处理;编译;汇编;链接;进程;存储;虚拟内存;I/O管理;
目 录
第1章 概述
1.1 Hello简介
P2P:From Program to Process,Linux环境下,hello.c经过cpp的预处理得到中间文件hello.i;然后经ccl编译后得到汇编语言文件hello.s;在经由as汇编后得到可重定位目标文件hello.o;最后由ld链接后得到可执行目标文件hello。用户在 shell键入./hello启动程序,shell调用fork函数产生子进程,hello便成为了进程。
020:From Zero-0 to Zero-0,shell为此子进程execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发与调试工具:gcc,vim,edb,readelf,HexEdit
1.3 中间结果
文件名 | 文件的作用 |
hello.c | hello的c语言源程序 |
hello.i | 预处理后的文件 |
hello.s | 编译后产生的汇编文件 |
hello.o | 汇编后产生的可重定位目标文件 |
hello | 可执行目标文件 |
hello1.txt | hello.o的反汇编 |
hello2.txt | hello的反汇编 |
hello.elf | hello.o的ELF格式 |
hello1.elf | hello的ELF格式 |
1.4 本章小结
本章简要介绍了helloP2P和020的整个过程,以及实验相关的环境、工具、中间文件。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念
预处理就是是预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。它得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,而是把源代码分割或处理成为特定的单位——预处理记号,用来支持语言特性(如C/C++的宏调用)。
预处理的作用
1、宏定义指令
宏定义了一个代表特定内容的标识符。预处理过程会把源代码中出现的宏标识符替换成宏定义时的值。宏最常见的用法是定义代表某个值的全局符号。宏的第二种用法是定义带参数的宏(宏函数),这样的宏可以象函数一样被调用,但它是在调用语句处展开宏,并用调用时的实际参数来代替定义中的形式参数。使用宏定义不仅方便,可读性强,而且容易修改。
2、条件编译指令
条件编译指令如#ifdef,#ifndef,#else,#elif,#endif等。程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。条件编译指令将决定哪些代码被编译,而哪些是不被编译的。可以根据表达式的值或者某个特定的宏是否被定义来确定编译条件。
3、头文件包含指令
采用头文件的目的主要是为了使某些定义可以供多个不同的C源程序使用。因为在需要用到这些定义的C源程序中,只需加上一条#include语句即可,而不必再在此文件中将这些定义重复一遍。预编译程序将把头文件中的定义统统都加入到它所产生的输出文件中,以供编译程序对之进行处理。
4.处理特殊符号
预编译程序可以识别一些特殊的符号。 例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
2.2在Ubuntu下预处理的命令
命令:gcc hello.c -E -o hello.i
图 1预处理命令
图 2预处理命令
2.3 Hello的预处理结果解析
图 3预处理结果,hello.i部分代码
经过预处理之后,hello.c变为hello.i文件,打开该文件可以发现,文件变为3000多行内容大大增加,且仍为可以阅读的C语言程序文本文件。对原程序中的宏进行了宏展开,头文件中的内容被包含进该文件中。例如声明函数、定义结构体、定义变量、定义宏等内容,如果代码中有#define命令还会对相应的符号进行替换。同时,预处理删除了原来的程序主体段的注释信息。
2.4 本章小结
本章介绍了预处理的概念,以及四种情况的作用,并对hello.c进行了预处理操作,分析了它的预处理结果。
第3章 编译
3.1 编译的概念与作用
编译的概念
编译是编译器将文本文件 hello.i 翻译成hello.s,它包含一个汇编语言程序。是将源语言经过词法分析、语法分析、语义分析以及经过一系列优化后生成汇编代码的过程。其以高级程序设计语言书写的源程序作为输入,而以汇编语言或机器语言表示的目标程序作为输出。
编译的作用
编译程序的作用是将高级语言源程序翻译成目标程序。除了基本功能之外,编译程序还具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人际联系等重要功能。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
图 3.2.1 编译
图 4编译
3.3 Hello的编译结果解析
3.3.1汇编初始部分
图 5汇编初始部分
.file | 声明源文件 |
.text | 代码节 |
.section | 只读代码段 |
.rodata | 只读代码段 |
.align | 数据或者指令的地址对其方式 |
.string | 声明一个字符串 |
.global | 声明全局变量 |
.type | 声明一个符号是函数类型还是数据类型 |
3.3.2数据
- 字符串
该程序有两个字符串,分别是:“用法: Hello 学号 姓名 秒数!\n”和从终端键入的,储存在argv[]为地址的数组中的“Hello %s %s\n”,它们都在只读数据段中,如下图所示。
图 6字符串部分
如图所示,这两个字符串是printf的参数。
图 7
- 局部变量
main中声明了一个int类型的局部变量i,局部变量运行时被保存在堆栈里。在hello.s文件中,编译器将i存储在栈空间-4(%rbp)中,如下图所示。
图 8
- 参数argc
参数argc作为用户传给main的参数,也是被保存在堆栈中。
- 数组
数组char *argv[]是hello.c中唯一的数组,作为main函数的第二个参数。数组中每个元素都是一个指针,指向一个字符串。数组一开始被存放在栈空间-32(%rbp)中,被两次调用传给printf。
图 9 数组
- 立即数
立即数以$开头,直接体现在汇编代码中。
3.3.3 赋值操作
Hello.c中的赋值操作在汇编代码中以mov指令体现。根据数据类型的不同,mov指令的后缀也不同:
movb:一个字节
movw:两个字节
movl:四个字节
movq:八个字节
3.3.4 类型转换
Hello.c中atoi()将字符串类型的argv[3]转换成整形,其他的类型转换还有int、float、double、short、char之间的转换。
3.3.5 算术操作
Hello.c中的算数操作是i++,用addl就可以实现,如下图所示:
汇编语言的其他算术操作:
ADD 加法.
ADC 带进位加法.
INC 加 1.
AAA 加法的ASCII码调整.
DAA 加法的十进制调整.
SUB 减法.
SBB 带借位减法.
DEC 减 1.
NEC 求反(以 0 减之).
MUL 无符号乘法.
IMUL 整数乘法.
3.3.6 关系操作
Hello.c中有两处关系操作。
- argc!= 4是在if语句中的一个条件判断,在汇编代码中体现为:
图 10
- i<8是for循环中的循环结束条件,在汇编代码中体现为:
图 11
3.3.7 控制转移
汇编语言中首先设置条件码,然后根据条件码来进行控制转移,在hello.c中,有以下控制转移指令:
- 判断argc是否等于4,如果不等于,则执行if语句,否则就跳过if语句,其汇编代码如下:
图 12
- 每次for循环中都要判断i是否小于8,如果不小于,就要跳转出for循环,其汇编代码如下:
图 13
3.3.8 函数操作
调用函数时有以下操作:(假设函数P调用函数Q)
(1)传递控制:进行过程 Q 的时候,程序计数器必须设置为 Q 的代码的起始 地址,然后在返回时,要把程序计数器设置为 P 中调用 Q 后面那条指令的 地址。
(2)传递数据:P 必须能够向 Q 提供一个或多个参数,Q 必须能够向 P 中返回 一个值。
(3) 分配和释放内存:在开始时,Q 可能需要为局部变量分配空间,而在返回 前,又必须释放这些空间。
在hello.c中,涉及到了以下几种函数调用:main(),printf(),exit(),sleep(),getchar(),atoi()。
Main函数的参数是int argc,char *argv[],printf()将程序中的两个字符串输出到屏幕上,exit()的参数是1,sleep()的参数是atoi(argv[3]),getchar()没有参数,atoi()的参数是argv[3]函数的返回值存储在%eax寄存器中。
3.4 本章小结
本章介绍了编译的概念和作用,并分别从c语言的数据,赋值语句,类型转换,算术操作,关系操作,控制转移与函数操作这几个方面对编译hello.c产生的汇编代码进行了简要的分析。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念
汇编就是汇编器(as)将Hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件Hello.o中,这个过程叫做汇编。Hello.o是一个二进制文件,它包含的是函数main的编码,如果我们在文本编辑器中打开hello.o文件,将看到一堆乱码。
汇编的作用
汇编将汇编代码转换为机器指令,使其在链接后能被机器识别并执行。
4.2 在Ubuntu下汇编的命令
命令:gcc -c -o hello.o hello.s
图 14汇编命令
图 15产生文件
4.3 可重定位目标elf格式
一个典型的ELF可重定位目标文件的格式如下图所示:
图 16典型的ELF可重定位目标文件
在linux中生成hello.o文件ELF格式的命令:readelf -a hello.o > hello.elf
图 17生成ELF格式命令
图 18生成的文件
分析ELF格式的文件:
- ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
图 19ELF头
图 47重定位算法
5.6 hello的执行流程
(以下格式自行编排,编辑时删除)
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
执行流程:
1.开始执行:_start、_libc_start_main
2.执行main:_main、_printf、_exit、_sleep、_getchar
3.退出:exit
子程序名 | 程序地址 |
hello!_start | 0x4010f0 |
libc-2.31.so!_libc_start_main | 0x7f7320ec5f73 |
printf@plt | 0x4010a0 |
getchar@plt | 0x4010b0 |
atoi@plt | 0x4010c0 |
sleep@plt | 0x4010e0 |
exit@plt | 0x4010d0 |
图 48
图 49edb调试
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
延迟绑定是通过两个数据结构的交互来实现的,这两个数据结构是 GOT(全局偏移量表)和 PLT(过程链接表)。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的 GOT 和 PLT。GOT 是数据段的一部分,PLT 是代码段的一部分。而其中我们关心的使GOT和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.so模块中的入口点。其余的每个条目对应与一个被调用的函数,其地址需要在运行时被解析。
在elf文件中我们可以从节头部表部分找到.got.plt的信息,可知.got.plt节的开始地址为0x404000,且大小为0x48。
图 50.got.plt
然后利用edb查看dl_init前后动态链接的变化,首先从edb的Data Dump打开0x403000~0x405000范围内的数据,找出0x404000 ~0x40403f即为dl_init的数据段,使用edb执行到dl_init,该段发生变化,如下图所示:
图 51(执行前)
图 52(执行后)
可以发现在dl_init后出现了两个地址,分别为0x7feaa7d17190和0x7feaa7d00bc0,这就是GOT[1]和GOT[2],同样利用edb查看GOT[2]内容,可以发现是动态链接函数:
图 53动态链接函数
5.8 本章小结
本章首先介绍了链接的概念和作用,之后分析了可执行文件hello的格式,分为ELF头,节头,重定位节,符号表,然后通过edb寻找到了hello的虚拟地址空间,并与可执行文件的内容对应。之后通过edb分析了hello的重定位过程,执行过程,动态连接过程,对链接有了更深的理解。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念
进程的经典定义是一个执行中程序的实例。
进程的作用
系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
6.2 简述壳Shell-bash的作用与处理流程
作用
Linux系统中,Shell是一个交互型应用级程序,为使用者提供操作界面,接收用户命令,然后调用相应的应用程序。
处理流程
1.从终端读入输入的命令。
2.分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。
3.检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令,如果是则立即执行。
4.若不是内置的shell命令且是一个可执行目标文件,则创建新的子进程,在子进程的上下文中加载运行该文件。
5.在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
6.如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wai等待作业终止后返回。
7.如果用户要求后台运行(如果命令末尾有&号),则shell返回。
6.3 Hello的fork进程创建过程
终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的 逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
图 54fork()创建进程详细步骤
6.4 Hello的execve过程
当创建了一个子进程之后,子进程调用exceve函数在当前子进程的上下文加载并运行一个新的程序即hello程序,加载并运行需要以下几个步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。如下图所示:
图 55
3.映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
5. execve在调用成功的情况下不会返回,只有当出现错误时,例如找不到需要执行的程序时,execve才会返回到调用程序。
6.5 Hello的进程执行
hello程序的执行是依赖于进程所提供的抽象的基础上,进程提供给应用程序的抽象有:
1.一个独立的逻辑控制流,它提供一个假象,好像我们的进程独占的使用处理器
2.一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用CPU内存。
操作系统所提供的的进程抽象有:
1.逻辑控制流::一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流 使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占 (暂时挂起),然后轮到其他进程。
2.并发流:一个逻辑流的执行时间与另一个流重叠
- 节头部表(section header table),记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。 由于是可重定位目标文件,所以每个节都从0开始,用于重定位。在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小,同时可以观察到,代码是可执行的,但是不能写;数据段和只读数据段都不可执行,而且只读数据段也不可写。
-
图 20节头部表
- 重定位节(Relocation section)
-
.rela.text是一个重定位表,也叫作重定位段,用于链接器在处理目标文件时,重定位代码段中那些对绝对地址的引用的位置。本程序需要被重定位的是printf、puts、exit、sleepsecs、getchar、sleep和.rodata中的.L0和.L1。
图 21.rela.text
.rela.eh_frame节是.eh_frame节重定位信息。
图 22.rela.eh_frame
- 符号表(Symbol Table)
-
每一个目标文件都会有一个相应的符号表,这个表里记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。
除了函数和变量之外,还存在其它几种不常用到的符号。符号表中所有符号可以分为如下几种:
- 全局符号。定义在本目标文件,可以被其它目标文件引用。
-
(2)外部符号(External Symbol)。在本目标文件中引用的全局符号,却没有定义在本目标文件。
(3)段名。其值为该段的起始地址。
(4)局部符号。这类符号只在编译单元内部可见,链接器往往会忽略它们,因为没用。
(5)行号信息。即目标文件指令与源代码中代码行的对应关系,它是可选的。
图 23符号表
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o > hello1.txt
图 24命令
图 25生成文件
图 26部分反汇编代码
hello.o的反汇编代码与hello.s文件总体大致相同,有小部分区别,反汇编代码所显示的不仅仅是汇编代码,还有机器指令码。区别如下:
- 分支转移
-
反汇编的跳转指令用的不是段名称比如.L3,而是用的确定的地址。但在反汇编代码中,分支转移表示为主函数+段内偏移量。反汇编代码跳转指令的操作数使用的不是段名称,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
Hello.s
图 27
Hello1.txt
图 28
- 函数调用
-
hello.s文件中,函数调用call后跟的是函数名称;而在hello.o文件中,call后跟的是下一条指令。因为这些函数都是共享库函数,地址是不确定的,因此call指令将相对地址设置为全0,然后在.rela.text节中为其添加重定位条目,等待链接的进一步确定。
Hello.s
图 29
Hello1.txt
图 30
- 立即数变为16进制格式
-
在编译文件中,立即数全部是以16进制表示的,因为16进制与2进制之间的转换比十进制更加方便,所以都转换成了16进制。
4.5 本章小结
本章首先介绍了汇编的概念和作用,然后分析了ELF格式的hello各部分的组成,之后对hello.o进行了解析,通过分析反汇编文件hello1.txt与hello.s在分支转移、函数调用和立即数三个方面的不同之处,来表示从汇编语言到机器语言的一一映射关系。
第5章 链接5.1 链接的概念与作用
链接的概念
链接是将各种代码和数据片段收集并合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
链接的作用
链接通常是由链接器来处理的,而链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
命令: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
图 31命令
图 32可执行文件hello
生成反汇编代码:
图 33部分反汇编代码
5.3 可执行目标文件hello的格式
命令:readelf -a hello > hello1.elf
- ELF头,与hello.o的ELF格式文件的不同之处已经在图中标出来了。
-
图 34ELF头
- 节头,下图展示了section header的一部分。
-
图 35节头
- 重定位节
-
图 36重定位节
- 符号表,下图展示了一部分的符号表。
-
图 37符号表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
分析程序头LOAD可加载的程序段的地址为0x400000
图 38可加载的程序段
观察edb的Data Dump,发现程序从0x401000开始,0x401ff0结束
图 39Data Dump
hello的虚拟地址空间开始于0x400000,结束与0x400ff0。
图 40虚拟地址空间
根据5.3中所展示的节头部表我们可以得知各个节的地址,从而在edb中找到各个节的信息。比如.text的开始地址在0x4010f0,就可以在edb中找到。
图 41.text节
图 42edb中的.text节
5.5 链接的重定位过程分析
命令:objdump -d -r hello > hello2.txt
Hello2.txt已经在5.1进行了展示。
通过分析hello与hello.o,发现了如下不同:
- hello反汇编的代码中有确定的虚拟地址,说明已经完成了重定位,而hello.o反汇编代码中代码的虚拟地址均为0,未完成可重定位的过程。如下图所示:
-
Hello2.txt
图 43
Hello1.txt
图 44
- Hello2.txt中多了很多的节以及很多函数的汇编代码,如下图所示,这些节都具有一定的功能和含义,而且可以从hello的elf格式文件中找到相关的信息。
-
图 45hello2.txt中多出的部分节
图 46hello2.txt多出的部分函数
链接的过程
链接就是链接器(ld)将各个目标文件(各种.o文件)组装在一起,文件中的各个函数段按照一定规则累积在一起。从.o提供的重定位条目将函数调用和控制流跳转的地址填写为最终的地址。
hello重定位的过程
- 重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
- 重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
- 重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目,代码的重定位条目放在.rel.txt中。已初始化数据的重定位条目放在.rela.data中。
- 重定位过程的地址计算算法如图所示:
,成为并发流,这两个流成为并发的运行。多个流并发的执行的一般现象成为并发。
3.时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
4.私有地址空间:进程为每个流都提供一种假象,好像它是独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,在这个意义上,这个地址空间是私有的。
5.用户模式和内核模式::处理器通常使用一个寄存器提供两种模式的区分,该寄 存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的 代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任 何命令,并且可以访问系统中的任何内存位置。
6.上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由 通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内 核数据结构等对象的值构成。
7.上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:
1) 保存以前进程的上下文
2)恢复新恢复进程被保存的上下文,
3)将控制传递给这 个新恢复的进程 ,来完成上下文切换。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个时间发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。如read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另一个进程;sleep系统调用显式地请求让调用进程休眠。
中断也可能引发上下文切换。所有系统都有某种产生周期性定时器中断的机制,每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
下图展示了进程A与B之间上下文切换的示例:
图 56
6.6 hello的异常与信号处理
异常和信号异常可以分为四类:中断、陷阱、故障、终止,各自的属性如图所示:
图 57
hello程序出现的异常可能有:
中断:在hello程序执行的过程中可能会出现外部I/O设备引起的异常。
陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep函数的时候会出现这个异常。
故障:在执行hello程序的时候,可能会发生缺页故障。
终止:终止时不可恢复的错误,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。
发出的信号有:
图 58
图 59正常运行
进程收到 SIGSTP 信号,hello 进程挂起。用ps查看其进程PID,可以发现hello的PID是3480;再用jobs查看此时hello的后台 job号是3,调用 fg3将其调回前台。
图 60
进程收到 SIGINT 信号,结束 hello。在ps中查询不到其PID,在job中也没有显示,此时hello已经被彻底结束。
图 61
程序运行过程中键盘不停乱按或者回车都不会影响输出,而在输出的循环结束了之后,程序调用getchar函数,读入了原先在运行过程中键盘乱按出的一行内容,并且后续乱按的内容也被不断地当成命令读入,说明在hello运行过程中,额外的输入会被缓存到输入缓冲区,直到程序结束后读出。
图 62
挂起的进程被终止,在ps中无法查到到其PID。
图 63
6.7本章小结
本章介绍了进程的概念与作用,Shell的一般处理流程,分析了hello进程的执行过程,创建、加载和终止,以及hello的异常与信号处理。在hello运行过程中,内核对其调度,异常处理程序为其将处理各种异常。每种信号都有不同的处理机制,对不同的shell命令,hello也有不同的响应结果。
第7章 hello的存储管理
7.1 hello的存储器地址空间
(以下格式自行编排,编辑时删除)
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
1.逻辑地址
逻辑地址(Logical Address)程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为[段标识符:段内偏移量]。在hello当中是指由程序hello产生的与段相关的偏移地址部分(hello.o)。
2.线性地址
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在hello当中,程序hello的代码会产生逻辑地址,或者说是(即hello程序)段中的偏移地址,它加上相应段的基地址就生成了一个线性地址。
3.虚拟地址
程序运行在保护模式下,程序访问存储器所使用的逻辑地址称为虚拟地址,虚拟内存被组织为一个存放在磁盘上的N个连续的字节大小的单元组成的数组,其每个字节对应的地址称为虚拟地址。对hello进行反汇编得到的每一节所在的地址均是逻辑地址的段偏移量部分,而段偏移量加上段基址即为虚拟地址,在这里虚拟地址与线性地址相同,而Linux的所有段基址均是0,所以此时逻辑地址与虚拟地址是相同的。
4.物理地址
物理地址(Physical Address)是CPU通过地址总线的寻址,找到真实的物理内存对应地址。CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。如果启用了分页机制,那么hello的线性地址会使用页目录和页表中的项变换成hello的物理地址;如果没有启用分页机制,那么hello的线性地址就直接成为物理地址了。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 Intel 平台下,逻辑地址(logical address)是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address。我们把这个过程称作段式内存管理。
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,表示具体的是代码段寄存器还是栈段寄存器抑或是数据段寄存器,
索引号就是“段描述符(segment descriptor)”的索引,段描述符具体地址描述了一个段。很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符。
全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
线性地址即虚拟地址(VA)到物理地址(PA)之间的转换通过分页机制完成,而分页机制是对虚拟地址内存空间进行分页。
系统将虚拟页作为进行数据传输的单元。Linux下每个虚拟页大小为4KB。物理内存也被分割为物理页, MMU(内存管理单元)负责地址翻译,MMU使用页表将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,例如VPN 0选择PTE 0。根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。这里的VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。
7.4 TLB与四级页表支持下的VA到PA的变换
在 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 位,所以 TLBT 32 位。
Core i7采用四级页表的层次结构。CPU产生虚拟地址VA,虚拟地址VA传送给MMU,MMU使用VPN高位作为TLBT和TLBI,向TLB中寻找匹配。如果命中,则得到物理地址PA。如果TLB中没有命中,MMU查询页表,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,以此类推,最终在第四级页表中找到PPN,与VPO组合成物理地址PA,添加到PLT。
图 64
7.5 三级Cache支持下的物理内存访问
CPU发送一条虚拟地址,随后MMU按照7.4所述的操作获得了物理地址PA。根据cache大小组数的要求,将PA分为CT(标记位)CI(组索引),CO(块偏移)。根据CI寻找到正确的组,依次与每一行的数据比较,有效位有效且标记位一致则命中。如果命中,直接返回想要的数据。如果不命中,就依次去L2,L3,主存判断是否命中,命中时将数据传给CPU同时更新各级cache的储存。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:
图 65
7.8 缺页故障与缺页中断处理
页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的。
处理流程:
图 66
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显示地被应用所分配。一个已分配的块保持已分配状态,直到它被释放。
动态内存分配主要有两种基本方法与策略:
带边界标签的隐式空闲链表分配器管理
使用边界标记的堆块的格式其中头部和脚部分别存放了当前内存块的大小与是否已分配的信息。通过这种结构,隐式动态内存分配器会对堆进行扫描,通过头部和脚部的结构实现查找。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配和最佳适配。分配器在面对释放一个已分配块时,可以合并相邻的空闲块,其中一种简单的方式,是利用隐式空闲链表的边界标记来进行合并。
显式空间链表管理
要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。在显式空闲链表中,可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章主要介绍了hello的存储器的地址空间,intel的段式管理、hello的页式管理,以intel Core7在指定环境下介绍了虚拟地址VA到物理地址PA的转换、阐述了三级cache物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件,所有的I/O设备都被模型化为文件,甚至内核也被映射为文件。
设备管理:unix io接口,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
我们可以对文件的操作有:打开关闭操作open和close;读写操作read和write;改变当前文件位置lseek等
8.2 简述Unix IO接口及其函数
Unix I/O 接口
Unix I/O 函数
8.3 printf的实现分析
1. static int printf(const char *fmt, ...)
2. {
3. va_list args;
4. int i;
5. va_start(args, fmt);
6. write(1,printbuf,i=vsprintf(printbuf, fmt, args));
7. va_end(args);
8. return i;
9.}
printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
1.write:
2. mov eax, _NR_write
3. mov ebx, [esp + 4]
4. mov ecx, [esp + 8]
5. int INT_VECTOR_SYS_CALL
在printf中调用系统函数write(buf,i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,
int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
1.sys_call:
2.call save
3.
4. push dword [p_proc_ready]
5.
6. sti
7.
8. push ecx
9. push ebx
10. call [sys_call_table + eax * 4]
11. add esp, 4 * 3
12.
13. mov [esi + EAXREG - P_STACKBASE], eax
14. cli
15. ret
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。于是我们的打印字符串就显示在了屏幕上。
8.4 getchar的实现分析
getchar的函数体:
1. int getchar(void)
2. {
3. static char buf[BUFSIZ];
4. static char *bb = buf;
5. static int n = 0;
6. if(n == 0)
7. {
8. n = read(0, buf, BUFSIZ);
9. bb = buf;
10. }
11. return(--n >= 0)?(unsigned char) *bb++ : EOF;
12. }
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
用户按下键盘后,键盘接口得到一个代表该按键的键盘扫描码,与此同时会产生一个中断请求,运行键盘中断子程序,先从键盘接口取得该按键的扫描码,而后扫描码被转换为ASCII码,保存到键盘的缓冲区当中。后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:当用户按键时触发键盘终端,操作系统将控制转移到键盘中断处理子程序,中断处理程序执行,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区,显示在用户输入的终端内。当中断处理程序执行完毕后,返回到下一条指令运行。
8.5本章小结
本章介绍了 Linux 的 I/O 设备的基本概念和管理方法,以及Unix I/O 接口及其函数。最后分析了printf 函数和 getchar 函数的工作过程。
结论
通过计算机系统这门课的学习,我对一个程序的产生和运行以及其背后的机制有了更深刻的了解。原来一个平平无奇的程序背后有那么多功能支持它运作。为了解决快的设备存储小、存储大的设备慢的不平衡,精巧的计算机系统设计了高速缓存来作为更底层的存储设备的缓存,从而大大提高了CPU访问主存的速度。同时计算机系统的设计也考虑了一切可能的实际情况,设计出一系列的满足不同情况的策略。比如写回和直写,写分配和非写分配,直接映射高速缓存和组相连高速缓存等等。
附件
文件名 | 文件的作用 |
hello.c | hello的c语言源程序 |
hello.i | 预处理后的文件 |
hello.s | 编译后产生的汇编文件 |
hello.o | 汇编后产生的可重定位目标文件 |
hello | 可执行目标文件 |
hello1.txt | hello.o的反汇编 |
hello2.txt | hello的反汇编 |
hello.elf | hello.o的ELF格式 |
hello1.elf | hello的ELF格式 |
参考文献
- hello出现异常的处理
- 正常运行
- 按下ctrl+z
- 按下ctrl+c
- 中途乱按
- kill命令
- 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结 构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名 文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长 度为零。
- 映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链 接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程 上下文的程序计数器,使之指向代码区域的入口点。
- 处理器生成一个虚拟地址,并将它传送给MMU
- MMU生成PTE地址,并从高速缓存/主存请求得到它
- 高速缓存/主存向MMU返回PTE
- PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
- 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
- 缺页处理程序页面调入新的页面,并更新内存中的PTE
- 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。流程图如下所示:
- 打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息,应用程序只需要记住这个描述符。
- Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO和STDERR_FILENO, 它们可用来代替显式的描述符值。
- 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行 seek,显式地将改变当前文件位置 k。
- 读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号”。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
- int open(char* filename,int flags,mode_t mode), 进程通过调用 open 函 数来打开一个存在的文件或是创建一个新文件的。 open函数将filename 转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在 进程中当前没有打开的最小描述符,flags 参数指明了进程打算如何访 问这个文件,mode 参数指定了新文件的访问权限位。
- int close(fd),fd是需要关闭的文件的描述符,close返回操作结果。
- ssize_t read(int fd,void *buf,size_t n),read 函数从描述符为fd的当前文件位置赋值最多 n 个字节到内存位置 buf。返回值-1 表示一个错误,0表示 EOF,否则返回值表示的是实际传送的字节数量。
- ssize_t wirte(int fd,const void *buf,size_t n),write 函数从内存位置 buf 复制至多 n 个字节到描述符为fd的当前文件位置。
- printf函数的内部结构
- write函数:
- syscall函数体
- 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':
- itoa(tmp, *((int*)p_next_arg));
- strcpy(p, tmp);
- p_next_arg += 4;
- p += strlen(tmp);
- 预处理,将放置在源文件的预处理指令修改源文件内容,预处理器cpp将hello.c转换为hello.i。
- 编译:将hello.i文件进行翻译生成汇编语言文件hello.s。
- 汇编:将hello.s翻译成一个可重定位目标文件hello.o。
- 链接,将可重定位目标文件与标准库进行链接,是程序可以加载到内存中来执行,链接器ld将hello.o与动态链接库链接生成可执行目标文件。
- 运行时,在终端输入./hello 学号 姓名 秒数,父进程通过调用fork创建子进程,通过调用execve在子进程上下文加载新的程序,在进程执行过程中可能会出现一些异常,此时内核会调用处理程序进行相关处理。
- 访问内存:程序加载到内存,CPU通过MMU对虚拟地址进行翻译,再进行数据的读写。
- 执行指令:CPU为进程分配时间片,在一个时间片中,hello享有CPU资源, 顺序执行自己的控制逻辑流。
- 动态申请内存:当hello程序执行printf函数是, 会调用 malloc 向动态内存分配器申请堆中的内存。
- 信号管理:当程序在运行的时候我们输入Ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
- 终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构。
- (美)布赖恩特(Bryant,R.E.).《深入理解计算机系统》 机械工业出版社,2016.
- 内存地址转换与分段_drshenlei的博客-CSDN博客
- [转]printf 函数实现的深入剖析 - Pianistx - 博客园
- break;
- case 's':
- break;
- default:
- break;
- }
- }
- return (p - buf);
- }