计算机科学与技术学院
2018年12月
本次大作业主要介绍hello程序的生命周期。从hello.c开始,经历各种处理变化形成可执行文件hello,最后加载到终端,运行,结束,完成hello程序的一生。
关键词:hello;处理变化;生命周期;
目 录
第1章 概述........................................................................................ - 4 -
1.1 Hello简介................................................................................. - 4 -
1.2 环境与工具................................................................................ - 4 -
1.3 中间结果.................................................................................... - 4 -
1.4 本章小结.................................................................................... - 5 -
第2章 预处理.................................................................................... - 6 -
2.1 预处理的概念与作用................................................................ - 6 -
2.2在Ubuntu下预处理的命令..................................................... - 6 -
2.3 Hello的预处理结果解析......................................................... - 6 -
2.4 本章小结.................................................................................... - 7 -
第3章 编译........................................................................................ - 8 -
3.1 编译的概念与作用.................................................................... - 8 -
3.2 在Ubuntu下编译的命令......................................................... - 8 -
3.3 Hello的编译结果解析............................................................. - 8 -
3.4 本章小结.................................................................................. - 13 -
第4章 汇编...................................................................................... - 15 -
4.1 汇编的概念与作用.................................................................. - 15 -
4.2 在Ubuntu下汇编的命令....................................................... - 15 -
4.3 可重定位目标elf格式........................................................... - 15 -
4.4 Hello.o的结果解析................................................................ - 18 -
4.5 本章小结.................................................................................. - 18 -
第5章 链接...................................................................................... - 20 -
5.1 链接的概念与作用.................................................................. - 20 -
5.2 在Ubuntu下链接的命令....................................................... - 20 -
5.3 可执行目标文件hello的格式.............................................. - 20 -
5.4 hello的虚拟地址空间............................................................ - 22 -
5.5 链接的重定位过程分析.......................................................... - 23 -
5.6 hello的执行流程.................................................................... - 25 -
5.7 Hello的动态链接分析........................................................... - 25 -
5.8 本章小结.................................................................................. - 26 -
第6章 hello进程管理............................................................... - 27 -
6.1 进程的概念与作用.................................................................. - 27 -
6.2 简述壳Shell-bash的作用与处理流程................................ - 27 -
6.3 Hello的fork进程创建过程................................................. - 27 -
6.4 Hello的execve过程............................................................. - 28 -
6.5 Hello的进程执行................................................................... - 29 -
6.6 hello的异常与信号处理........................................................ - 30 -
6.7本章小结................................................................................... - 32 -
第7章 hello的存储管理........................................................... - 33 -
7.1 hello的存储器地址空间........................................................ - 33 -
7.2 Intel逻辑地址到线性地址的变换-段式管理....................... - 33 -
7.3 Hello的线性地址到物理地址的变换-页式管理.................. - 35 -
7.4 TLB与四级页表支持下的VA到PA的变换........................ - 37 -
7.5 三级Cache支持下的物理内存访问..................................... - 38 -
7.6 hello进程fork时的内存映射.............................................. - 40 -
7.7 hello进程execve时的内存映射.......................................... - 40 -
7.8 缺页故障与缺页中断处理...................................................... - 41 -
7.9动态存储分配管理................................................................... - 41 -
7.10本章小结................................................................................. - 44 -
第8章 hello的IO管理............................................................ - 45 -
8.1 Linux的IO设备管理方法...................................................... - 45 -
8.2 简述Unix IO接口及其函数................................................... - 45 -
8.3 printf的实现分析................................................................... - 46 -
8.4 getchar的实现分析............................................................... - 48 -
8.5本章小结................................................................................... - 48 -
第1章 概述
1.1 Hello简介
在codeblock中编写自己的代码,完成hello.c程序的书写。
在linux系统中,hello.c预处理器cpp预处理,生成hello.i文件;hello.i经过编译器cc1处理,生成hello.s文件;hello.s经过汇编器as处理生成hello.o文件;最后hello.o文件经过连接器ld处理,生成可执行目标程序hello。
图1.1.1 由hello.c生成hello的流程
在用户终端shell写入./hello 1173710105 曾钰城,其中可执行目标程序hello。shell为其开辟一个子进程,这就是program到process的过程,即P2P过程。
shell通过调用execve,execve通过调用某个驻留在存储器中称为加载器的操作系统代码来运行hello,加载器将可执行目标文件中的代码和数据从磁盘中复制到内存中,然后通过跳转到程序的第一条指令或者入口点来运行该程序,当加载器运行时,会为hello创建内存映像。当程序运行结束后,hello的父进程负责回收hello进程的内存,则就是O2O过程。
1.2 环境与工具
1.2.1 硬件环境
CPU:Inter Core i5-7200U X86_64PC
内存:8GB RAM
1.2.2 软件环境
Windows:
OS版本号:
Windows 10 版本17134.1
IDE:
Codeblock 17.12(自带编译器)
其他:
Winhex 19.7
Gcc version:6.3.0
OllyDBG
Linux:
OS版本号:
Ubuntu 16.4
IDE:
Codeblock16.01
其他:
Winhex 19.7
Gcc version: 5.4.0
GNU gdb version : 7.11.1
Notepad++
EDB
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名字 | 文件作用 |
hello.i | 预处理之后源程序 (ASCII文本) |
hello.s | 编译之后的汇编程序 (ASCII文本) |
hello.o | 汇编之后的可重定位目标执行(二进制文本) |
hello | 链接之后的可执行目标文件(二进制文件) |
hello_o_asm.txt | hello.o 的反汇编代码 |
hello_o_elf.txt | hello.o 的 ELF 信息 |
hello_asm.txt | hello 的反汇编代码 |
hello_elf.txt | hello的 ELF信息 |
1.4 本章小结
简单介绍p2p以及o2o的流程,注明了实验环境和工具,列出了实验的中间生成文件。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器cpp根据特殊的关键字(#include,#define和#if) ,修改源程序,生成处理之后的源程序.i文件。
作用:
- 插入#include命令的文件
- 拓展所有#define声明指定的宏
- 根据#if 后面的条件决定需要编译的代码
2.2在Ubuntu下预处理的命令
命令:gcc -E hello.c -o hello.i
图2.2.1 使用gcc预处理器cpp生成hello.i文件
2.3 Hello的预处理结果解析
用notepad++打开hello.i文件,可以观察的经过cpp处理之后,hello.c源程序已经拓展为3127行。
文件结构:文件可以分成4部分,分别是 stdio.h的展开,unistd.h的展开,stdlib.h的展开,以及我们自己编写的hello.c程序的内容。hello.c的代码出现在3107行,
图2.3.1 hello.i中main函数的样式以及位置
简述预处理操作流程:
1)插入#include命令的文件:以#include <stdio.h>关键字为例,gcc的预处理器cpp到默认的到环境变量目录/usr/include下寻找stdio.h,将其内容插入到hello.i文件中,如果发现中还存在# include,gcc递归执行上述操作,直到所有# include都被插入到hello.i文件中。
2)拓展所有#define声明指定的宏:例如#define max 100,预处理器cpp会将所有对max的引用替换为100。
3)根据#if后面的条件决定需要编译的代码:cpp 会对条件值进行判断来决定是否执行包含其中的逻辑
2.4 本章小结
本章主要介绍了预处理的概念以及作用,并且通过实际操作,观测预处理的效果,并对结果进行解释说明。
第3章 编译
3.1 编译的概念与作用
概念:将文本文件hello.i(ASCII文本)翻译成文本文件hello.s(ASCII文件),hello.s是一个由汇编语言构成的程序,这个处理过程称为编译。
作用:将C语言语句翻译成汇编语言语句,完成这个过程主要由以下五个步骤构成:
1)词法解析:从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序
2)语法解析:单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,将词法分析得到结果构建一颗语法树。
3)生成中间代码:作用是可使编译程序的结构在逻辑上更为简单明确。
4)代码优化:代码优化是指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。
5)生成目标代码:目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
图3.2.1 使用gcc编译器cc1生成hello.s文件
3.3 Hello的编译结果解析
3.3.1 数据
在hello.c中用到了C语言的数据类型有:整形、字符串
- 整形
本程序设置到的整数有:
- int sleepsecs = 2.5;sleepsecs在源程序中被声明为全局变量,且是已经被符初值,故其为强符号,其在.data节中。编译器在处理时首先在代码段.text中将sleepsecs声明为全局变量,其次在.data中将sleepsecs声明为全局变量。下图为经编译器处理后,sleepsecs在hello.s中的声明
图3.3.1.1 全局变量sleepsecs的声明
- int i:编译器将局部变量储存在寄存器或者在用户栈空间之中。由图中可以看出,编译器将i放在栈-4(%rbp)空间中。
- int argc:作为main函数的第一个参数,放在寄存器%edi中。后来由将其放入栈空间-20(%rbp)处,
- 立即数:直接出现在汇编代码之中,无需另外开辟空间。
- 字符串
程序中的字符串有两个:
1)”Usage: Hello 1173710105 曾钰城!\n”,第一个printf的打印内容,字符串常量在.rodata段里面,在hello.s中声明形式如下
2)” "Hello %s %s\n"”,第二个printf的输出格式参数,字符串常量在.rodata段里面,在hello.s中声明形式如下
图3.3.1.2声明在.LC0 和.LC1 段中的字符串
3.3.2 赋值
在程序hello.c中涉及到的赋值操作只有两个,分别是:
- int sleepsecs=2.5:因为sleepsecs是全局变量,且是强符号类型,所以会在.data节中将sleepsecs声明为值2得int类型数据
- i=0:因为i是局部变量,故i在栈上分配内存空间,初始化时的赋值语句可以用传送语句完成,将立即数传送相应的栈地址空间即可,如图:;在更新i的值时,可以用算数运算指令更新相应的栈地址空间即可,如图:
3.3.3 类型转换
本程序中只涉及一个隐式类型转换:int sleepsecs = 2.5,将浮点数类型隐式转换为int类型。
C语言浮点数常量默认是double类型,当double型强制转换为int类型时,会遵循向0舍入原则,即将2.5舍入为2,然后将2赋值给sleepsecs。
3.3.4 算术运算
本程序中涉及到的算术操作有:
- 自增运算i++,这里运用的是指令是addl,l代表这是一个4字节的运算,用语句addl $1, -4(%rbp),实现对计数器i的更新
3.3.5 关系操作
常见的关系操作:
指令 | 基于 | 描述 |
CMP S1,S2 | S1-S2 | 设置条件码而不更新寄存器 |
TEST S1,S2 | S1&S2 | 设置条件码而不更新寄存器 |
SETXX D | D=(<CC>,XX) | 根据条件码设置D |
程序中涉及的关系运算为:
- argc!=3:判断argc是否等于3,在汇编语言中用语句cmpl $3,-20(%rbp)来实现,计算argc-3的值,然后根据值设置条件码,这个条件码在接下来的流程控制语句je中会用到。
- i<10:判断i是否小于10。在汇编语言中用语句 cmpl $9, -4(%rbp)来实现,计算i-9的值,然后根据i-9值设置条件码,这个条件码在接下来的流程控制语句jle中会用到。
3.3.6数组/指针
本程序中只有一个数组:char *argv[],里面存放char形指针,其作为main函数的第二个参数输入。
argv数组中每一个元素的大小为 8字节,数组的起始地址为 argv,其中每一个元素指向一个已经分配好的、一片存放着字符指针的连续空间(在栈空间上),其内容为用户在命令行输入的在后面的参数;本程序中main函数中访问数组元素argv[1],argv[2]时,按照相对寻址的方式,起始地址为argv, 偏移量的大小为8的倍数,通这二者可以计算数据地址,进而取数据。在hello.s 中,寻址语句如下:
movq %rsi,-32(%rbp),寄存器%rsi保存第二个参数(即起始地址argv),将其保存在栈空间-32(%rbp)上。
图3.3.6.1 hello.s中数组寻址
第一个参数访存:先将数组首地址传给%rax中,通过计算%rax←%rax+16,获取第一个参数argv[1]地址,然后访存,将argv[1]的内容传给%rdx。
第二个参数访存:先将数组首地址传给%rax中,通过计算%rax←%rax+8,获取第一个参数argv[2]地址,然后访存,将argv[2]的内容传给%rax。
3.3.7控制转移
控制转移主要通过转跳指令完成:
指令 | 转跳条件 | 描叙 |
JXX Label | (<CC>,XX) | 根据XX与条件码进行转跳到相应为位置 |
本程序总览设计到的关系运算为:
- if(argc!=3):当argc不等于3的时候执行printf语句,然后退出;否则,执行另外一部分语句。如下图:首先通过cmpl $3,-20(%rbp)语句设置条件码,然后通过转跳指令je .L2 判断条件码ZF是否为1,如果ZF为1,则转跳到L2,如果ZF为0,则顺序执行je .L2后面的语句。
图3.3.7.1 hello.s中的控制转移语句if(argc!=3)
- for(i=0;i<10;i++):循环语句,如果i<10,执行循环体里面的内容,否则跳出循环。如下图,在.L2中首选执行初始化语句,然后无条件转跳到.L3。在.L3中,首先通过cmpl $9,-4(%rbp)语句设置条件码,然后通过转跳指令jle .L4判断条件码(SF^OF)|ZF是否为1,如果(SF^OF)|ZF为1,则转跳到L4,执行循环体里面的语句,如果(SF^OF)|ZF为0,则顺序执行jle .L4后面的语句。
图3.3.7.2 hello.s中的控制转移语句for(i=0;i<10;i++)
3.3.8函数操作
函数调用是一个过程,函数是一种过程,过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能,然后可以在程序中不同的地方调用这个函数,为讨论方便,假设过程P调用了过程Q,Q执行后返回到P,这些动作包含一个或多个机制:
1) 传递控制: 进行过程 Q 的时候,程序计数器必须设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
2) 传递数据:P 必须能够向Q 提供一个或多个参数, Q必须能够向 P 中返回一个值。
3) 分配和释放内存:在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
X86_64系统中的部分寄存器分类:
调用者保存:
%r10 | %r11 |
倍调用者保存:
%rbx | %rbp | %r12 |
%r13 | %r14 | %15 |
用于传递参数的寄存器:
参数顺序 | 1 | 2 | 3 | 4 | 5 | 6 |
寄存器 | %rdi | %rsi | %rdx | %rcx | %r8 | %r9 |
注:当参数个数超过7个时,则通过栈传递参数,这个用于存放参数的区域叫做参数构造区
其他的一些寄存器的作用:
%rsp 用于保存栈指针
%rbp 用于保存帧指针
%rax 用于存放返回值
本程序设计到函数:
- main函数:
- 控制传递:系统调用main函数(通过call指令完成),这时系统把call指令后面的指令地址保存起来(push %rip),然后转跳到main函数的第一条语句。Main函数通过ret返回,ret 相当 pop %rip,将下一条要执行指令的地址设置为call main的下一条指令。
- 传递数据:用户在命令行上输入参数,这些参数分别对应main函数中的argc和argv,在main函数中,分别保存在寄存器%rdi以及%rsi中。在main函数通return 0先其调用者返回0,表示正常退出,这通过将寄存器设%eax置为0完成
- 分配内存:寄存器%rbp记录帧指针,寄存器%rsp记录栈指针,在程序开始,通过将%rsp的值将少去一个8的倍数的值来开辟一个栈空间。
释放内存:程序结束时,将%rsp加上一个8的倍数的值,恢复额外申请的栈空间,然后调用 leave 指令, leave 相当mov %rbp,%rsp以及pop %rbp,这些操作的作用是恢复栈空间为调用之前的状态。
- printf函数:
- 控制传递:main函数调用printf(通过call puts指令完成),这时系统把call指令后面的指令地址保存起来(push %rip),然后转跳到puts函数的第一条语句。puts函数通过ret返回,ret 相当 pop %rip,将下一条要执行指令的地址设置为call puts的下一条指令。
- 传递数据:第一次printf函数的调用的参数设置:将字符串"Usage: Hello 1173710105 曾钰城!\n"的首地址传递给%rdi。第二次printf函数的调用的参数设置:将字符串“Hello %s %s\n”的首地址传递为%rsi 为 argv[1],%rdx 为 argv[2]。
- exit()函数:
- 控制传递: main函数调用exit(通过call exit指令完成)
- 参数传递:通过语句movl $1, %edi,将%edi设置为1
- sleep函数:
- 控制传递: main函数调用sleep(通过call sleep指令完成)
- 参数传递:通过语句movl sleepsecs(%rip), %eax,movl %eax, %edi将%edi设置为全局变量sleepsecs
- getchar 函数:
- 控制传递: main函数调用getchar(通过call getchar指令完成)
- 参数传递:无参数传递
3.4 本章小结
本章主要阐述了P2P过程中的编译器的概念以及作用,解析hello.c到hello.s的映射关系,以及解析编译完成后的结果。
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将hello.s汇编程序(ASCII文件)翻译成机器语言指令,把汇编程序中的指令生成一个可重定位目标文件(relocatable object program),并且把结果保存在.o文件中(二进制文件)。
4.2 在Ubuntu下汇编的命令
命令:gcc -c hello.s -o hello.o
图4.2.1使用gcc汇编as指令生成hello.o文件
4.3 可重定位目标elf格式
在命令行中输入readelf -a hello.o > hello_o_elf.txt,获得hello.o文件的ELF。
hello.o文件的ELF的格式:
图4.3.1 ELF文件中各个节之间的关系
- ELF头:以16字节序列Magic开始,Magic 描述了生成该文件的系统的字的大小和字节顺序, ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息, 其中包括 ELF 头的大小、目标文件的类型、 机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
图4.3.2 hello.o文件中ELF表头内容
- 节区头部表(Section Header Table):包含了描述文件各个节的信息,比如大小、偏移等。
图4.3.3hello.o文件中ELF 区头部表内容
- 重定位节'.rela.text':一个.text节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者应用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。如下图就包含本程序的重定位信息:
图4.3.4hello.o文件中ELF .rela.text内容
- 重定位节 '.rela.eh_frame':eh_frame重定位信息
图4.3.5hello.o文件中ELF .rela.eh_frame内容
4.4 Hello.o的结果解析
使用objdump -d -r hello.o > hello_o_asm.txt获得反汇编代码。
与hello.s进行对照分析,主要差异如下:
1) 全局变量:对于全局变量的访问与hello.s不同,在hello.s中,对于全局变量的访问,通过字节使用声明中的助记符+off+%rip来访问;而在反汇编代码中,对于全局变量的访问,用$0x0(%rip)代替,这是因为.data与.rodata段中的数据地址在运行时才确定的,故对全局变量的访问需要重定位,所以对全局变量的访问需要添加重定位条目。
2) 分支转移:反汇编代码转跳指令语句中目的地址使用的是实际地址,而hello.s中转跳指令语句中目的地址使用的是助记符.LX,由此可以确定目的地址映射关系,在汇编语言映射到机器语言时,目的地址的助记符会被替换成实际地址。
3) 函数调用:在hello.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call 的目标地址是当前下一条指令地址(注:这并不是目标函数的地址)。这是因为hello.c中调用的函数不是本地函数,而是共享库中的函数,需要通过动态链接器才能确定函数的运行时真正地址,在生成为机器语言的时候,这些地址并不确定,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全目标地址正是下一条指令,然后在.rela.text节中为其添加重定位条目,在连接阶段静态链接会重定位。
映射关系图:
图4.4.1 hello.s到hello.o 的映射关系图
4.5 本章小结
本章主要介绍了汇编器(as)的概率以及作用,介绍.o文件的ELF内容以及结构,比较hello.o的反汇编代码与hello.s的差异,以及两者之间的映射关系。
第5章 链接
5.1 链接的概念与作用
链接(linking)是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被编译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至于运行时(run time),也就是由应用程序来执行。链接是由叫做链接器(linker)的程序执行的,链接器使得分离(separate compilation)编译成为可能。
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 使用ld命令动态链接生成可执行程序 hello
注意:这里使用不是gcc,而是ld命令,故需要在连接的时候在命令行输入依赖的库,且要注意顺序。
5.3 可执行目标文件hello的格式
在终端中写入readelf -a hello > hello_elf.txt,生成可执行文件hello的ELF格式文件信息。
可执行目标文件hello的ELF格式 与 可重定位目标文件hello.o 基本相同:
- 在节头表中列出可执行文件中各段的信息,其中包含名称(大小),类型(全体大小),地址(旗标、连接、信息)以及偏移量(对齐)。
图5.3.1 hello中的ELF 节头信息
- 程序头:程序头表描述了可执行文件的连续片与连续内存段之间的映射关系,根据程序表头的内容,可以根据可执行文件的内容初始化两个内存片段。
图5.3.2 hello中的ELF 程序头信息
off:目标文件之中的偏移
vaddr/paddr:内存地址
align:对齐要求
filesz:目标文件中的段大小
mensz:内存中的段大小
flags:运行时访问权限
5.4 hello的虚拟地址空间
使用edb打开hello程序,通过左下角小窗Data Dump可以本进程的虚拟地址空间各段信息。
程序被分配到虚拟地址为0x0000000000400000~0x0000000000401000的虚拟空间上,程序的开始地址为0x0000000000400000,结束地址为0x0000000000400ff0,各个段的顺序与节表头的顺序一一对应。
查看 ELF 格式文件中的程序头,程序头表在执行的时候被使用,有表中可以得知内存与段之间的映射关系。表中得到每一个项提供了各段的基本信息:
off:目标文件之中的偏移
vaddr/paddr:内存地址
align:对齐要求
filesz:目标文件中的段大小
mensz:内存中的段大小
flags:运行时访问权限
程序表头共包含8个段:
1) PHDR:保存程序头表。
2) INTERP:指定在程序已经从可执行文件映射到内存之后,必须调用的解释
器(如动态链接器) 。
3) LOAD:只读代码段。
4) LOAD:读写数据段:表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串) 、程序的目标代码等。
5) DYNAMIC:保存了由动态链接器使用的信息。
7) NOTE:保存辅助信息。
7) GNU_STACK: 权限标志,标志栈是否是可执行的。
8) GNU_RELRO: 指定在重定位结束之后那些内存区域是需要设置只读。
图5.4.1 hello中的ELF 程序头信息
5.5 链接的重定位过程分析
在命令行上输入objdump -d -r hello > hello_asm.txt 获得 hello 的反汇编代码
图5.5.1 在终端中输入命令,获得hello的反汇编代码
Hello的反汇编文本hello_asm.txt与hello.o的返汇编文本hello_o_asm.txt相比,多了一些段,以及函数调用、指令和操作数的地址发生了改变。
多出来节的作用:
节名称 | 作用 |
.init | 程序初始化执行的代码 |
.plt | 静态连接的连接表 |
.plt.got | 保存函数引用的地址 |
.fini | 程序正常终止时执行的代码 |
通过比较两者汇编代码差异,理解动态连接器的作用:
- 动态连接函数:在连接时,因为指定了动态库libc.so、ld-linux-x86-64.so.2,
和重定位目标文件crt1.o,crti.o和crtn.o,所以当创建可执行文件时,静态执行一些连接,然后在程序加载时,动态完成连接过程。连接器复制了一些重定位信息和符号表信息,这是可执行程序运行时可以解析对库函数的引用。在本程序中,连接器将用到的外部函数printf、sleep、getchar、exit 函数和_start 中调用的__libc_csu_init, __libc_csu_fini,__libc_start_main加入。 - 函数调用:链接器解析重定条目时发现对外部函数调用的类型为的重定位类型,此时动态链接库中的外部函数已经加入到了节.plt中,.plt节相对位置已经确定,链接器通过计算函数与节.plt的相对距离来确定函数的相对地址,将对动态链接库中目标函数的引用值改为目标函数相对.plt的相对地址。
- 重定位:在连接阶段,连接器将所有的相同类型的节合并为同一类型的新的聚合节,然后连接器将运行时内存地址赋值给新的聚合节,赋值给输入模块定义的每个节,以及以及赋值给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址。
- 重定位节中的符号引用:在这一步中,连接器修改代码节的符号节中的引用,使它们指向正确的运行时地址,完成这一步,连接器需要依赖可重定位目标模块中称为重定位条目。
重定位条目数据结构:
typedef struct{
long offset; //节内偏移
long type:32, //重定位类型
symbol:32; //说绑定的符号
long addend; //偏移调整
}Elf64_Rela
计算重定位引用的方法:
- R_X86_64_PC32:32位相对地址引用
程序中对函数的调用都是相对地址引用:EG:
offset = 0x63
symbol = sleep
type = R_X86_64_PC32
addend = -0x4
ADDR(s) = ADDR(.plt) = 0x400450
ADDR(symbol) = ADDR(sleep) = 0x4004b0
refaddr = ADDR(.plt) + offset = 0x400450 + 0x63 = 0x4004b3
*refaddr =(unsigned)(ADDR(symbol) +r.addend - refaddr)
=(unsigned)0x60
故call 指令后面的地址值为0x400450+0x60=0x4004b0
- R_X86_64_32:32位绝对地址引用
本程序中对.rodata数据的引用是用绝对地址引用,EG:
offset = 0x16
symbol = .rodata
type= R_X86_64_32
addend = 0x8
ADDR(symbol) = ADDR(.rodata)=0x400600
*refptr = (unsigned)(ADDR(symbol) + addend)
= (unsigned)(0x400600 + 0x8)
= (unsigned)(0x400608)
故对字符串"Usage: Hello 1173710105 曾钰城"的引用地址值为0x400608
5.6 hello的执行流程
使用edb执行hello,观测程序运行,以下是从加载hello到_start,到call main,以及程序终止的所有过程:
程序名称 | 程序地址 |
ld-2.27.so!_dl_start | -- |
ld-2.27.so!_dl_init | -- |
hello!_start | 0x400500 |
libc-2.27.so!__libc_start_main | -- |
-libc-2.27.so!__cxa_atexit | -- |
-libc-2.27.so!__libc_csu_init | 0x4005c0 |
hello!_init | 0x400488 |
libc-2.27.so!_setjmp | -- |
-libc-2.27.so!_sigsetjmp | -- |
--libc-2.27.so!__sigjmp_save | -- |
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 | -- |
-ld-2.27.so!_dl_fixup | -- |
--ld-2.27.so!_dl_lookup_symbol_x | -- |
libc-2.27.so!exit 0x7fce 8c889128 | -- |
因为hello执行过程会调用到共享库中的函数,因为共享库中的代码是位置无关的代码,故在不同系统不同时刻运行时,程序地址会不同,故这里--代替这些程序地址。
5.7 Hello的动态链接分析
1) 装载时重定位:对于动态共享库中的函数,在编译阶段无法获得其真实地址;在动态连接阶段,连接器将共享函数设置一个重定位项,其中包含重定位信息(ADDR(.plt)+offset)和符号表信息,然后在运行时根据重定位项信息才能确定其真实地址。
2) PIC(地址无关代码):动态链接库希望所有进程共享指令段而各自拥有数据段的私有副本,为了实现这个目标,就要采用与地址无关代码的技术。该实现的基本思想是:把指令中需要修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分则在每个进程拥有一个副本。
与地址无关的代码,也就是需要考虑代码中会对地址进行引用的情况,共享对象(GCC中的动态链接文件)中地址引用可以分为以下几种情况:
a) 模块内函数调用、跳转:由于此时调用者与被调者都是位于同一个模块,所以调用者与被调者之间的相对位置是固定的,因此,对被调者的调用就可以使用相对地址来替代绝对地址,对于这种指令就是不需要重定位的。
b) 模块内数据的访问,如模块内定义的静态变量,全局变量:与上面分析同理,由于数据定义与引用指令是位于同一个模块的,因此它们之间的相对位置是固定的。但是此时有一些区别,现代体系结构中,数据的相对寻址没有基于当前指令的寻址方式,因此 ELF 采用了一个巧妙的方法来获取当前的PC(程序计数器)的值,再在该基础上添加一个偏移,即可访问到变量。
c) 模块外部的函数调用、跳转:此时对外部符号的引用显然是与地址有关的,按照先前说的基本思想,此时需要将与地址相关的部分放到数据段里。ELF 的做法是在数据段中建立一个指向这些函数的指针数组,也即是全局偏移表(GOT,Global Offset Tabel),当代码需要引用这些外部函数时,则可以通过GOT 中的相对应的项间接引用。动态链接器在装载模块的时候会查找每个函数所在地址,并填充GOT中的各个表项,以保证每个指针均指向正确的地址。同时由于GOT本身是放在数据段的,因此它可以在模块装载的时候被修改,并且每个进程都可有自己的副本。
d) 模块外部的数据的访问,比如别的模块定义的全局变量:该方法与模块外部的函数访问方法相同,同样引入 GOT ,只是此时GOT 的表项中存储的是变量的地址。
5.8 本章小结
本章主要介绍了链接的概念与作用,在Linux系统下如何进行连接,分析可执行文件的hello格式,分析hello的虚拟地址空间,逐一解析链接的重定位过程分析,分析hello的执行流程以及分析Hello的动态链接分析。
第6章 hello进程管理
6.1 进程的概念与作用
进程为用户提供了以下假象: 我们的程序好像是系统中当前运行的唯一程序一样, 我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用:Shell是一个用C语言编写的是一个命令行解释器,他是用户使用 Linux 的桥梁,为用户提供了一个向Linux内核发送请求以便运行程序的界面系统级程序,用户可以用shell来启动、挂起、停止甚至是编写一些程序。
处理流程:
1) 读取用户由键盘输入的命令行。
2) 分析命令,以命令名作为文件名,并将其它参数改造为系统调用execve( )内部处理所要求的形式。
3) 终端进程调用fork( )建立一个子进程。
4) 终端进程本身调用waitpid()来等待子进程完成(如果是后台命令,则不等待)。当子进程运行时调用execve(),子进程根据文件名到目录中查找有关文件,调入内存,执行这个程序。
5) 如果命令末尾有&,则终端进程不用执行系统调用waitpid (),立即发提示符,让用户输入下一条命令;否则终端进程会一直等待,当子进程完成工作后,向父进程报告,此时中断进程醒来,作必要的判别工作后,终端发出命令提示符,重复上述处理过程。
6.3 Hello的fork进程创建过程
在终端输入./hello 1173710105 曾钰城,shell会对输入进行命令行解释,发现hello不是内置命令,而是一个文件名,进而将1173710105 曾钰城 这两个参数改造为系统调用execve( )内部处理所要求的参数形式。然后shell会调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着,当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。父进程与子进程之间最大的区别在于它们拥有不同的 PID。
父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。如果在shell上无作特殊说明,则在子进程执行期间,父进程(shell)默认选项是显式等待子进程的完成,如指定为后台程序,则无需等待子进程完成。
示意图如下:
图6.3.1 fork与waitpid 操作示意图
6.4 Hello的execve过程
在命令行输入./hello 后,因为hello不是一个内置shell命令,所以shell会认为hello是一个可执行目标文件,execve通过调用某个驻留在存储器中称为加载器的操作系统代码来运行hello,加载器将可执行目标文件中的代码和数据从磁盘中复制到内存中,然后通过跳转到程序的第一条指令或者入口点来运行该程序,当加载器运行时,会hello创建内存映像,如下图:
图6.4.1 进程地址空间示意图
加载并运行hello需要以下几个步骤:
- 删除已存在的用户区域
- 映射私有区域
- 映射贡献区域
- 设置程序计数器
execve函数加载并运行可执行文件hello,且带参数列表argv和环境变量列表envp。argv变量指向一个以null结尾的指针数组,其中每一个指针都指向一个参数字符串。envp变量指向一个以变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串。
6.5 Hello的进程执行
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由一些对象的值组成,这些对象包括通用寄存器、浮点寄存器、 程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程轮流使用处理器,其提供一个假象,好像我们的程序独占地使用处理器。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常是某个控制寄存器中的一个为模式来提供这种功能的,该寄存器描述了当前进程享有的特权。当设置了模式位时,进程就运行在内核模式中,一个运行在内核模式的进程可以执行指令集中任何指令,并且可以访问系统中的任何内存位置。当没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,也不允许用户模式之中的进程直接引用地址空间中内核区内的代码和数据,如果直接引用会导致保护故障,所以用户程序必须通过系统调用接口间接地访问内核代码和数据。
Hello程序执行过程:在hello加载完成之后,hello顺序执行,如果有另外一个程序抢占了CPU的使用权,那么hello就要交出CPU的使用权,这时要进行上下文切换,上下文切换是由操作系统内核的代码调度完成的,上下文切换操作包括1)保存当前进程上下文。2)恢复某个先前被抢占的进程被保存的上下文。3)将控制传递给这个新恢复的进程。当hello进程调用sleep之后,程序显式地请求进入休眠状态,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出并且加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器计时完毕时发送一个中断信号, 此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为运行状态, hello进程就可以继续进行自己的控制逻辑流了。上下文切换示意图如下:
图 6.5.1 hello进程中调用sleep函数前后,进程上下文切换剖析
当hello调用getchar时,系统调用read,内核选择进行上下文切换,运行另外一个进程,而不是等待数据到达。内核中的陷阱处理程序请求来自键盘缓冲区的 DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回 hello进程,示意图如下:
图 6.5.2 hello进程中调用getchar函数前后,进程上下文切换剖析
6.6 hello的异常与信号处理
1)正常执行:hello程序正常执行,途中不作任何中断程序的输入,在执行完毕之后,hello进程被其父进程回收。
图6.6.1 hello进程正常运行示意图
- 在执行时按下ctrl+z:当按下ctrl+z时,内核会发送一个SIGTSTP信号给前台进程组中的每进程,其父进程会接受到SIGTSTP,结果是将hello进程挂起,通过ps查看hello进程并没有被回收。hello的进程号为1,通过在命令行输入fg 1,将hello程序调到前台,继续执行,此时hello程序继续输出信息,输出完毕后再次输入,程序结束,由其父进程回收。
图6.6.2 hello进程被ctrl+z挂起示意图
- 在执行时按下ctrl+c:在程序执行时按下ctrl+c时,内核会给送SIGINT信号给前台进程组中的每一个进程,hello的父进程接受SIGINT,结果是终止hello作业,并且回收进程hello,可以通过ps查看,发现hello已经被回收。
图6.6.3 hello进程被ctrl+c终止示意图
- 随便乱按:在hello执行时随便乱按,可以发现,乱按只是在屏幕上输出对应字符,当hello程序中循环执行结束之后,执行getchar函数,getchar读入一个以’\n’结尾的字符串,其他剩下的字符串作为shell命令行的输入。
图6.6.4 hello进程乱按时示意图
6.7本章小结
本章介绍进程的概念与作用,简述了壳Shell-bash的作用与处理流程,结合具体情况介绍hello的fork进程创建过程、hello的execve过程和hello的进程执行。最后还解析了在执行hello时各种异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:由程序产生的与段相关的偏移地址部分,逻辑地址由两个16位的地址分量构成,一个为段基值,另一个为偏移量。
线性地址:是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址后加上基地址就是线性地址。
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,每一个字节都有一个唯一的物理地址。
虚拟地址:由CPU生产,经过MMU转换可以转换为物理地址,虚拟地址实际上就是一种线性地址。
几者之间的关系:
图7.1.1存储器地址空间机制
逻辑地址出现在hello.o的反汇编代码中,虚拟地址出现在hello反汇编代码中,因为在连接阶段进行了重定位操作,指令以及大部分引用都已经被映射到虚拟内存空间中。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式内存管理方式就是直接将逻辑地址转换成线性,其逻辑地址的基本组成方式是段基值+段内偏移地址。
逻辑空间:逻辑空间分为若干个段,其中每一个段都定义了一组具有完整意义的信息,逻辑地址对应于逻辑空间。
段: 段是对程序逻辑意义上的一种划分,一组完整逻辑意义的程序被划分成一段,所以段的长度是不确定的。
段描述符:段描述符段中的元素,用于描述一个段的详细信息的结构,段描述符一般是由8个字节组成,常见结构如下
图7.2.1 逻辑地址组成示意图
逻辑地址到线性地址的转换过程:
步骤:
- 首先获取一个完整的逻辑地址[段选择符(段基值):段内偏移地址],
- 看段选择描述符中的T1字段是0还是1,可以知道当前要转换的是GDT中的段,还是LDT中的段,再根据指定的相应的寄存器,得到其地址和大小,我们就有了一个数组了。
- 拿出段选择符中的前13位,可以在这个数组中查找到对应的段描述符,这样就有了Base,即基地址就知道了。
- 把基地址Base+Offset,就是要转换的下一个阶段的物理地址。
图7.2.2 逻辑地址翻译成线性地址示意图
注: GDT:全局段描述符
LDT : 局部段描述符表
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(虚拟地址VA)到物理地址(PA)之间的转换通过分页机制以及页式管理来完成。
Linux虚拟内存区域:Linux将虚拟内存组织成一些段的集合 一些区域就是已经分配的虚拟内存的连续片。内核为系统中每个进程维护一个单独的任务结构(task_struct)。任务结构中的元素包含或者指向内核运行该进程所需的所有信息。任务结构中的一个条目指向mm_struct,它描述了虚拟内存的当前状态,其中的pgd字段指向第一级页表(页全局目录)的基地址;其中的mmap指向一个vm_area_structs(区域结构)的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域,一些具体区域的区域结构如下:
vm_start:指向这个区域的起始位置
vm_end:指向这个区域的结束处
vm_prot:描述这个区域包含的所有页的读写许可权限
vm_flags:描述这个区域内包含的所有页的读写许可权限
vm_next:指向链表中下一个区域结构
当内核运行这个进程时,pgd存放在CR3控制寄存器中。
图7.3.1 虚拟地址空间组织形式
操作系统通过将虚拟内存空间以页为单位分割管理,一页大小4KB;类似地,物理内存空间也以页以页为单位分割管理,一页大小4KB。通过MMU,可以将虚拟地址映射到唯一物理地址。
虚拟地址组成:虚拟地址由两部分组成VPN和VPO。VPN为虚拟页号,在虚拟地址高位位置;VPO为虚拟页偏移量,在虚拟地址低位位置。VA具体占用多少位,由系统环境决定;在Intel Core i7环境下,VPN占据高36位,VPO占据低12位。
物理地址组成:物理地址由两部分组成PPN和PPO。PPN为物理页号,在物理地址高位位置;PPO为物理页偏移量,在物理地址低位位置。PA具体占用多少位,由系统环境决定;在Intel Core i7环境下,PPN占据高40位,VPO占据低12位。
翻译方式如下图,不考虑保护位、TLB和多级页表。首先CPU产生一个虚拟地址VA,并且把VA传送给MMU,MMU分割成VPN和VPO。通过页表基址寄存器 PTBR+VPN在页表中获得条目PTE,一条PTE中包含有效位和物理页号。如果有效位是1则代表该内存已经缓存在了物理内存中,可以得到其物理页号PPN,与虚拟页偏移量VPO共同构成物理地址PA;如果是有效位0+非空。则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中,这时引发缺页异常,系统将调用缺页处理机制;如果有效位是0+空则代表没有在虚拟内存空间中分配该内存,则说明非法内存访问,程序终止。
图7.3.2 线性地址到物理地址映射机制
7.4 TLB与四级页表支持下的VA到PA的变换
在Intel Core i7采用四级页表层次结构。Core i7支持48位虚拟地址空间和52位物理地址空间。
虚拟页表组成:虚拟页是4KB对齐,一条PTE条目为8字节,一页有512个条目,TLB 4路 16 组相联
虚拟地址组成:VA是48位,VPN是36位,VPO是12位。因为TLB共 16 组,所以TLBI需4位,因为VPN为 36 位,所以TLBT为32位 和 TLBI为4位,且因为有4级页表,故VPN可以依次切分为4个VPNi(9位),i=1,2,3,4。
VA到PA转换过程如下图:
CPU产生虚拟地址VA,并将VA传给MMU,MMU使用前36位分离出32为的TLBT和4位的TLBI,在TLB中匹配。如果匹配成功(命中),则则得到PPN(40位),然后和VPO(12位)组合成52位。如果TLB不命中,则MMU使用VPN在页表中查询,CR3确认第一级页表的起始地址,然后使用VPN1(9位)作为索引在第一级页表里面查找,查询出PTE,如果这个PTE为有效,则这个PTE作为第二级页表的其中地址,VPN2作为第二个页表的索引,以此类推,最终在最后一级页表中(第四级页表中),查询结果作为物理地址的PPN,然后与VPO结合组成PA,并且更新TLB。
如果任意一级页表中按照VPNi查询失败,则说明发生了缺页故障,需要请求内核修复。
图7.4.1 TLB与四级页表支持下的VA到PA的变换示意图
7.5 三级Cache支持下的物理内存访问
图7.5.1 Core i5 L1缓存
L1 Cache 结构:8路64组相连,块大小为64字节
C=32KB S=64 B=64byte E=8
s=6 b=6 e=3
图7.5.2 Core i5 L2缓存
L2 Cache 结构:4路1024组相连,块大小为字节。
C=256KB S=1024 B=64byte E=4
s=10 b=6 e=2
图7.5.3 Core i5 L3缓存
L3 Cache 结构12路4096组相连,块大小为64字节。
C=3072KB S=4096 B=64byte E=12
s=12 b=6 e=4
首先讨论在L1 Cache结构下的访问细节:
地址组成:因为共64组,所以s=6,需要6位CI进行组寻址;因为块大小为 64B 所以b=6,需要6位CO计算数据偏移位置;因为PA共 52bit,所以t=40,需要40位CT进行组内匹配。
图7.5.4 字地址组成示意图
寻址:首先通过计算组索引CI的值,找到组号;然后匹配CT,如果匹配成功且块的有效位为1,则命中;最后根据偏移量CO,找到数据的首地址,取出数据。如果匹配不成功或者匹配成功但是有效位为0,则发生不命中,然后向下一级缓存中查询数据,查找顺序为L2 Cache->L3 Cache->主存(注不同的缓存结构,地址组成CO,CI,CT所占位有所不同,但是查找方法一样),如果都找不到,则报错。如果找到数据之后,则将该块数据放置到上一级缓存当中,放置策略为:若果有对应组内有空闲块,则放置到空闲块当中;若果组内没有空闲块,则选择牺牲一块,通常采用最近最小使用原则LRU选择牺牲块,如果牺牲块已经被修改过,则将其写回到下一级缓存,然后将数据块覆盖掉牺牲块。
图示如下:
图7.5.5 用PA进行三级Cache的数据访问示意图
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,fork创建了当前进程的mm_struct,区域结构和页表的原样副本。fork将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记私有的写时赋值。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中任一个后来进行写操作时,写时复制机制就会创建新页面。
7.7 hello进程execve时的内存映射
execve通过调用某个驻留在存储器中称为加载器的操作系统代码来运行hello,加载器将可执行目标文件中的代码和数据从磁盘中复制到内存中,然后通过跳转到程序的第一条指令或者入口点来运行该程序,当加载器运行时,会hello创建内存映像。加载并运行 hello 需要以下几个步骤:
1)删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2)映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
3)映射共享区域:hello 程序与共享对象(或者目标)链接,libc.so是动态链库,那么这些对象都是接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4)设置程序计数器(PC):execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
用户地址空间的区域映射图像:
图7.7.1 execve的内存映射
7.8 缺页故障与缺页中断处理
CPU产生一个虚拟地址VA,MMU将VA翻译成一个所以来定位PTE,并从内存中读取它,如果发现PTE相对应的物理地址未缓存到内存中(页表中对应的有效位为0),就会引发缺页故障,这时hello程序会陷入内核,内核调用缺页处理机制处理缺页故障。
缺页故障处理流程:内核从磁盘中复制相应的物理页到内存中的对应位置,更新PTE,随后返回。当异常处理程序返回时,它会重新启动缺页的指令,该指令重新把导致缺页的虚拟地址重新发送MMU,重新开始寻址。
处理流程图如下:
图7.8.1故障处理机制示意图
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。 一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块,但不同之处的在于是由哪个实体负责释放已分配的块:
显式分配器: 要求应用显式地释放任何已分配的块。
隐式分配器: 要求分配器检测一个已分配块何时不再使用,那么就释放这个块,隐式分配器也叫垃圾收集器。
分配器的数据结构:
1、带边界标签的隐式空闲链表分配器原理
a) 边界标记的堆块格式:
图7.9.1 隐式空闲列表堆块组织格式示意图
在每块的开头处添加一个头部,结尾处添加一个脚部,每一个脚部就是一个头部的副本。头部和脚部组成:低3位作为是否空闲的标记位,覆盖低位之后的整个头部/脚部作为整个大小的标记
b) 隐式链表:
所谓隐式空闲链表,指的是并不直接对空闲块进行链接,而是将对内存空间中的所有块组织成一个大链表,其中头部和脚部中的信息隐式地起到连接的作用。
c)合并空闲块:
考虑当分配器释放当前块时所有可能存在的情况:
- 前面的块和后面的块都是己分配的。
- 前面的块是已分配的,后面的块是空闲的。
- 前面的块是空闲的,而后面的块是已分配的。
- 前面的和后面的块都是空闲的。
分别对应以下四种情况:
图7.9.2 堆块合并示意图
d)放置已分配的块:
采用以下三种策略中一种:
首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块。
下一次适配:是从上一次查询结束的地方开始搜索空闲链表。
最佳适配:检查每个空闲块,选择适合所需请求大小的最小空闲块。
e)分割空闲快:
将空闲块分割成两部分,第一部分变成已分配块,第二部分变成新的空块。在找到空闲块时,若空闲块比当前所需求的块大,可以可选地分割这个块。
2、显示空间链表基本原理
a) 堆中块的结构:
堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如下图:
图7.9.3 显式空闲列表堆块组织格式示意图
b)显示链表:
将整个堆组织成显示链表,空闲块中的pred(前驱)和succ(后继)指针分别指向前后的一个空闲块。显示链表有不同组织形式,常见的有三种,分别是:简单分离存储、分离适配、伙伴系统。
c)合并空闲块:
方法同上。
d)放置已分配的块:
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
维护链表的顺序有: 后进先出(LIFO),将新释放的块放置在链表的开始处,使用 LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比 LIFO 排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
e)分割空闲快:
注:一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部,这就导致了更大的最小分配块大小,也潜在地提高了内部碎片的程度。
将空闲块分割成两部分,第一部分变成已分配块,第二部分变成新的空块。在找到空闲块时,若空闲块比当前所需求的块大,可以可选地分割这个块。如果分割出的空闲块比最小块小,则没有必要分割。
7.10本章小结
本章主要介绍了存储器地址空间、逻辑地址到线性地址的变换-段式管理机制、线性地址到物理地址的变换-页式管理机制、TLB与四级页表支持下的VA到PA的变换的具体过程、三级Cache支持下的物理内存访问、fork时的内存映射、execve时内存映射、缺页故障与缺页中断处理机制 和 动态存储分配管理机制。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的I/O设备都被模型化为文件,内核也被映射为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口称为Unix I/O。
8.2 简述Unix IO接口及其函数
Unix I/O 接口统一操作:
1)打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需要记住这个描述符。
2)Linux Shell创建的每个进程都有三个打开的文件:标准输入(描述符为0),标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可以用来代替显式的描述符值。
3)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置 k,初始为0。这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek操作,显式地将改变当前文件位置为k。
4)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到 k+n。给定一个大小为m字节的文件,当k>=m时执行读写操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。类似地,写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5)关闭文件:当应用完成了对文件的访问之后,它就会通知内核关闭这个文件。作为相应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。无论一个进程因何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O 函数:
- int open(char* filename,int flags,mode_t mode);
进程通过调用open函数来打开一个存在的文件或是创建一个新文件的,若成功则返回的为新文件的描述符,若出错则返回-1。open 函数将 filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件;mode参数指定了新文件的访问权限位。
flags参数如下:
O_RDONLY:只读
O_WDONLY:只写
O_RDWR:可读可写
O_CREAT:如果文件不存在,就创建它的一个截断的空文件
O_TRUNC:如果文件已经存在,就截断它
O_APPEND:在每次写操作前,设置文件位置到文件的结尾处。
- int close(int fd);
fd是需要关闭的文件的描述符, 返回操作结果为整形。若成功则返回0,若出错则返回-1。
- 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的当前文件位置。
8.3 printf的实现分析
研究printf的实现:
1、printf函数的函数体
图8.3.1 printf函数的函数体
va_list的定义: typedef char *va_list 这说明它是一个字符指针类型。
arg是是一个字符指针,(char*)(&fmt) + 4) 表示的是...中的第一个参数。
2、查看vsprintf代码:
图8.3.2 vsprintf函数的函数体
vsprintf的参数分别是:输出缓冲区buf,格式化参数fmt,输出字符串内容args
vsprintf返回的是要打印出来的字符串的长度
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出
3、write()函数的实现:
在printf中调用write(buf,i),将长度为i的buf输出。
write的底层实现如下:
图8.3.3 write函数的底层实现
在write函数中,将栈中参数放入寄存器,%ecx是字符个数,%ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall,查看syscall的实现
4、sys_call的实现:
图8.3.4 sys_call函数的底层实现
解释: ecx中是要打印出的元素个数
ebx中的是要打印的buf字符数组中的第一个元素
这个函数的功能就是不断的打印出字符,直到遇到:'\0'
[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
syscall将字符串中的字节“Hello 1173710105 曾钰城”从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
5、字符显示驱动子程序:
从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ASCII码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ASCII码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法,介绍Unix IO接口及其函数,分析了printf的实现和getchar的实现。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
- 编写源程序:用编译器将代码写入,形成hello.c文件(ASCII码文件)
- 预处理:hello.c经过预处理器cpp处理,将库文件加入到文件中,解析宏定义,最后形成一个hello.i文件(ASCII码文件)
- 编译:hello.i文件经过编译器(ccl)处理,将高级语言翻译成汇编语言,形成hello.s文件(ASCII文件)
- 汇编:hello.s经过汇编器as处理,形成可重定位文件hello.o(二进制文件)
- 连接:hello.o与可从定位目标文件和动态链接库连接,重定位节中的符号引用,最终形成可执行目标程序hello(二进制文件)
- 加载运行:在终端shell中输入./hello 1173710105 曾钰城。shell 调用 execve, execve通过调用某个驻留在存储器中称为加载器的操作系统代码来运行hello,加载器将可执行目标文件中的代码和数据从磁盘中复制到内存中,然后通过跳转到程序的第一条指令或者入口点来运行该程序。
- 创建子进程:shell进程作为父进程,通过调用fork创建子进程。
- 执行:创建上下文,通过逻辑控制流逐条执行汇编语句。
- 异常处理:当hello调用系统函数或者出现异常时,会进入内核模式,除法异常处理机制。
- 信号:在运行途中,在终端shell输入crtl+c或者ctrl+z,内核会发送相应信号给进程,则会导致进程停滞或者挂起。
- 内存访问:CPU生成一个VA,用过MMU翻译形成PA,最后访问内存,读取数据,其中会经历TLB、多级页表和多级缓存机制。
- 动态内存分配:hello会调用malloc函数,向系统申请内存动态空间。
- 结束返回:当hello从通过return返回时,其父进程删除hello进程内存区域
附件
文件名字 | 文件作用 |
hello.c | 源程序(ASCII文本) |
hello.i | 预处理之后源程序 (ASCII文本) |
hello.s | 编译之后的汇编程序 (ASCII文本) |
hello.o | 汇编之后的可重定位目标执行(二进制文本) |
hello | 链接之后的可执行目标文件(二进制文件) |
hello_o_asm.txt | hello.o 的反汇编代码 |
hello_o_elf.txt | hello.o 的 ELF 信息 |
hello_asm.txt | hello 的反汇编代码 |
hello_elf.txt | hello的 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.
[7] CSDN HIT CSAPP 2018 大作业 程序人生 Hello's P2P
https://blog.csdn.net/hahalidaxin/article/details/85144974