HIT CS:APP 计算机系统大作业 《程序人生-Hello’s P2P》
HIT CS:APP 计算机系统大作业
程序人生-Hello’s P2P
Hello的自白
我是Hello,我是每一个程序猿¤的初恋(羞羞……)
l却在短短几分钟后惨遭每个菜鸟的无情抛弃(呜呜……),他们很快喜欢上sum、sort、matrix、PR、AI、IOT、BD、MIS……,从不回头。
l只有我自己知道,我的出身有多么高贵,我的一生多么坎坷!
l多年后,那些真懂我的大佬(也是曾经的菜鸟一枚),才恍然感悟我的伟大!
l……………………想当年: 俺才是第一个玩 P2P的: From Program to Process
l懵懵懂懂的你笨笨磕磕的将我一字一键敲进电脑存成hello.c(Program),无意识中将我预处理、编译、汇编、链接,历经艰辛,我-Hello一个完美的生命诞生了。
l你知道吗?在壳(Bash)里,伟大的OS(进程管理)为我fork(Process),为我execve,为我mmap,分我时间片,让我得以在Hardware(CPU/RAM/IO)上驰骋(取指译码执行/流水线等);
l你知道吗?OS(存储管理)与MMU为VA到PA操碎了心;TLB、4级页表、3级Cache,Pagefile等等各显神通为我加速;IO管理与信号处理使尽了浑身解数,软硬结合,才使我能在键盘、主板、显卡、屏幕间游刃有余, 虽然我在台上的表演只是一瞬间、演技看起来很Low、效果很惨白。
l感谢 OS!感谢 Bash!在我完美谢幕后为我收尸。 我赤条条来去无牵挂!
l我朝 CS(计算机系统-Editor+Cpp+Compiler+AS+LD + OS + CPU/RAM/IO等)挥一挥手,不带走一片云彩! 俺也是 O2O: From Zero-0 to Zero-0。
l历史长河中一个个菜鸟与我擦肩而过,只有CS知道我的生、我的死,我的坎坷,“只有 CS 知道……我曾经……来…………过……”————未来一首关于Hello的歌曲绕梁千日不绝 !!
大作业要求
l根据Hello的自白,按照“HITICS-2018大作业模板”的要求编写。
lHello.c见附件(不是 Hello World!),且gcc –m64 –no-pie –fno-PIC
l根据模板章节顺序撰写,要求格式规范,条理清晰。
l论文内容应图文并茂,有理有据。图可以是结构图、流程图、程序调试的截图等,图中应有必要的标识指示,图下要有图的编号与名字。
l大作业提交截止日期2018年12月31日24点。
l大作业应同时发布到你公开的自媒体中,如博客或空间中。并截图-含地址
l大作业以文件名“学号+姓名+2018CS大作业.zip”提交到课代表。压缩包包括:大作业论文“CS大作业论文.docx”、“CS大作业自媒体截图.JPG”、“hello.c”、以及论文中附件部分列出的各个文件。
l非.docx 文件-1分,不按模板格式-1分,非图文并茂-2分,条理不清-1分。
l摘要、结论、每章小结等撰写中,感悟或创新较优秀的可加1-2分。
l大作业实行防抄袭、雷同的检查,一经发现并确认,成绩为0
l大作业总分10分,论文 9 分,自媒体发表验证 1 分。
参考:C语言的数据与操作
l数据:常量、变量(全局/局部/静态)、表达式、类型、宏
l赋值 = ,逗号操作符,赋初值/不赋初值
l类型转换(隐式或显式)
lSizeof
l算术操作:+ - * / % ++ – 取正/负± 复合“+=”等
l逻辑/位操作:逻辑&& || ! 位 & | ~ ^ 移位>> << 复合操作如 “|=” 或“<<=”等
l关系操作:== != > < >= <=
l数组/指针/结构操作:A[i] &v *p s.id p->id
l控制转移:if/else switch for while do/while ?: continue break
函数操作:参数传递(地址/值)、函数调用()、函数返回 return
摘要
本文遍历了hello.c在Linux下生命周期,借助Linux下系列开发工具,通过对其预处理、编译、汇编等过程的分步解读及对比来学习各个过程在Linux下实现机制及原因。同时通过对hello在Shell中的动态链接、进程运行、内存管理、I/O管理等过程的探索来更深层次的理解Linux系统下的动态链接机制、存储层次结构、异常控制流、虚拟内存及UnixI/O等相关内容。旨在将课本知识与实例结合学习,更加深入地理解计算机系统的课程内容。
关键词
操作系统;编译;链接;虚拟内存;异常控制流;
目录
第1章 概述
第2章 预处理
第3章 编译
第4章 汇编
第5章 链接
第6章 HELLO进程管理
第7章 HELLO的存储管理
第8章 HELLO的IO管理
[8.2 简述UNIX IO接口及其函数](#8.2简述Unix IO接口及其函数)
第1章概述
1.1Hello简介
P2P:From Program to Process
gcc编译器驱动程序读取程序文件hello.c,然后由预处理器(cpp)根据以#字符开头的命令,修改该程序获得hello.i文件,通过编译器(cll)将文本文件hello.i翻译成文本文件形式的汇编程序hello.s,再通过汇编器(cs)将hello.s翻译成机器语言指令,将指令打包成可重定位的目标文件hello.o,然后通过链接器(ld)合并获得可执行目标文件hello。Linux系统中通过内置命令行解释器shell加载运行hello程序,为hello程序fork进程,至此,hello.c完成了P2P的过程。
O2O:From Zero-0 to Zero -0
Shell通过execve在fork产生的子进程中加载hello,先删除当前虚拟地址的用户部分已存在的数据结构,为hello的代码、数据、bss和栈区域创建新的区域结构,然后映射共享区域,设置程序计数器,使之指向代码区域的入口点,进入main函数,CPU为hello分配时间片执行逻辑控制流。hello通过Unix I/O管理来控制输出。hello执行完成后shell会回收hello进程,并且内核会从系统中删除hello所有痕迹,至此,hello完成O2O的 过程。
1.2环境与工具
硬件环境
Intel(R)Core™i5-7200U CPU 2.50GHx 2.70GHz 8G RAM X64 256GSSD + 1TGHD
软件环境
操作系统:Winsows 64
虚拟机:Vmware 14
Linux:16.04
开发工具
gcc ld edb readelf gedit hexedit objdump
1.3中间结果
-
hello.c:源代码
-
hello.i:hello.c预处理生成的文本文件。
-
hello.s:hello.i经过编译器翻译成的文本文件hello.s,含汇编语言程序。
-
hello.o:hello.s经汇编器翻译成机器语言指令打包成的可重定位目标文件
-
hello.elf:hello.o的ELF格式。
-
hello_o_asm.txt:hello.o反汇编生成的代码。
-
hello:经过hello.o链接生成的可执行目标文件。
-
hello_out.elf:hello的ELF格式。
-
hello_out_asm.txt:hello反汇编生成的代码。
1.4本章小结
本章主要是漫游式地了解hello在系统中生命周期,对每个部分需要有系统地了解;同时本章列出本次实验的基本信息。
第2章预处理
2.1预处理的概念与作用
概念:预处理是编译器在编译开始之前调用预处理器来执行以#开头的命令(读取头文件、执行宏替代等)、删除注释、包含其他文件、执行条件编译、布局控制等修改原始的C程序,生成以.i结尾的文本文件。
主要功能:
1. 文件包含。例如hello.c中第一行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h文件的内容,并把它直接插入到程序文本中;
2. 删除注释;
3. 执行宏替代。宏是对一段重复文字的简短描写,例如#define MAX 2147483647在预处理中会把所有MAX替代为2147483647,#define MAX(x,y) ((x)>(y))?(x): (y)在预处理中会把所有MAX(x,y)替换为((x)>(y))?(x): (y)。
4. 条件编译。是根据实际定义宏(某类条件)进行代码静态编译的手段。可根据表达式的值或某个特定宏是否被定义来确定编译条件。例如:
#if | 编译预处理中的条件命令,相当于C语法中的if语句 |
---|---|
#ifdef | 判断某个宏是否被定义,若已定义,执行随后的语句 |
#elif | 若#if, #ifdef, #ifndef或前面的#elif条件不满足,则执行#elif之后的语句,相当于C语法中的else-if |
#else | 与#if, #ifdef, #ifndef对应, 若这些条件不满足,则执行#else之后的语句,相当于C语法中的else |
#endif | #if, #ifdef, #ifndef这些条件命令的结束标志. |
#ifndef | 与#ifdef相反,判断某个宏是否未被定义 |
defined | 与#if, #elif配合使用,判断某个宏是否被定义 |
5. 布局控制。布局控制的主要功能是为编译程序提供非常规的控制流信息。
2.2在Ubuntu下预处理的命令
Linux终端下进入hello.c所在文件,输入指令gcc –E –o hello.i hello.c,按下回车即可。
图2.2.1生成预处理文本文件
2.3Hello的预处理结果解析
在预处理文本文件hello.i中,首先是对文件包含中系统头文件的寻址和解析,如下:
图2.3.1 hello.i内容
因为hello.c包含的头文件中还包含有其他头文件,因此系统会递归式的寻址和展开,直到文件中不含宏定义且相关的头文件均已被引入。同时引入了头文件中所有typedef关键字,结构体类型、枚举类型、通过extern关键字调用并声明外部的结构体及函数定义,如下图(举部分例子):
图2.3.2 hello.i内容(2)
图2.3.3 hello.i内容(3)
图2.3.4 hello.i内容(4)
在最后3107行引入main函数,如下:
图2.3.4 hello.i中main函数
2.4本章小结
本章了解了预处理的概念及作用,以及Ubuntu下预处理对应的指令,同时解析了预处理文本文件内容,更直观的展现预处理的结果。
第3章编译
3.1编译的概念与作用
概念: 编译是利用编译程序从预处理文本文件产生汇编程序(文本)的过程。主要包含五个阶段:词法分析;语法分析;语义检查、中间代码生成、目标代码生成。
作用:编译作用主要是将文本文件hello.i翻译成文本文件hello.s,并在出现语法错误时给出提示信息,执行过程主要从其中三个阶段进行分析:
\1. 词法分析。词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序;
\2. 语法分析。语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位;
\3. 目标代码生成。目标代码生成器把语法分析后或优化后的中间代码经汇编程序汇编生成汇编语言代码,成为可执行的机器语言代码。
3.2在Ubuntu下编译的命令
Linux终端下进入hello.c所在文件,输入指令gcc –S –o hello.s hello.i,按下回车即可。
图3.2.1生成hello.s
3.3Hello的编译结果解析
3.3.1 汇编文件伪指令
指令 | 对应内容 |
---|---|
.file | 声明源文件 |
.text | 声明代码段 |
.data | 声明数据段 |
.align | 声明指令及数据存放地址的对齐方式 |
.type | 指定类型 |
.size | 声明大小 |
.section .rodata | 只读数据 |
.globl | 全局变量 |
3.3.2 数据类型
\1. 字符串型
汇编语言中,输出字符串作为全局变量保存,因此存储于.rodata节中。汇编文件hello.s中,共有两个字符串,均作为printf参数,分别为:
图3.3.2.1 输出字符串(1)
原本字符串为“Usage: Hello 学号 姓名!”,对应中文字符可以看出对中文字符进行了utf-8编码,中文汉字以‘\345’开头,占三个字符,而全角字符‘!’占用两个字符。
图3.3.2.2 输出字符串(2)
该字符串为printf格式化输出的字符串,正常保存。
\2. 整型
针对汇编文件中出现的整型数:
图3.3.2.3 全局变量sleepsecs
在hello.c文件中,sleepsecs的定义如下:
图3.3.2.4 hello.c中全局变量定义
sleepsecs被定义为整型全局变量,因此对2.5进行向下取整,sleepsecs为2,根据对齐方式.align为4,sleepsecs的size大小定义为了4Byte。hello.c中定义sleepsecs为整型,但是在hello.s中可以看出其类型为.long。做个实验查看其原因是否为初始化值的原因。定义test1.c,内容如下:
图3.3.2.5 测试文件test1.c
生成对应汇编文件,如下:
图3.3.2.6 测试文件test1.s
由此可看出,编译器ccl会将int表示为long但对齐方式仍为int型的4字节,long类型表示为双字quad,对齐方式仍为8字节。
此外,还有main函数内部的整型数,例如main函数参数argc,其为函数第一个参数,保存于栈空间中-0x20(%rbp),占4个字节大小;局部变量i位于栈空间-0x4(%rbp)位置,占4个字节大小;还有立即数,如判断argc!=3、i<10等直接作为立即数存放于指令中。
\1. 数组
在hello.c中有对数组的应用,如下:
图3.3.2.7 hello.c循环输出部分
数组为main函数参数,循环代码为:
图3.3.2.8 hello.s循环输出部分
argv[2]作为printf函数的第三个参数,应当存于寄存器%rdx中,因此可推断argv[2]地址为-0x16(%rbp);argv[1]作为printf第二个参数,应当存于寄存器%rsi中,因此可推断argv[1]地址为-0x2A(%rbp)中,数组首地址位于-0x32(%rbp) ,以上所占字节数为8。
3.3.3 汇编语言操作
\1. 数据传送指令
a) MOV类
MOV类主要由4条指令组成:movb、movw、movl、movq。主要区别在于他们操作的数据大小不同分别为1,2,4,8字节。
指令 | 效果 | 描述 |
---|---|---|
MOV S,D | D<—S | 传送 |
movb | R<—I | 传送字节 |
movw | R<—I | 传送字 |
movl | R<—I | 传送双字 |
movq | R<—I | 传送四字 |
movabsq I ,R | R<—I | 传送绝对的四字 |
在hello.s中也存在着大量的MOV数据传送,例如:
图3.3.3.1 数据传送指令(1)
图3.3.3.2 数据传送指令(2)
\1. 算数和逻辑操作
指令 | 效果 | 描述 |
---|---|---|
leaq S,D | D<—&S | 加载有效地址 |
INC D | D<—D+1 | 加一 |
DEC D | D<—D-1 | 减一 |
NEG D | D<—-D | 取负 |
NOT D | D<—~D | 取补 |
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<<k | 左移 |
SHL k,D | D<—D>>k | 左移(等同于SAL) |
SAR k,D | D<—D>>(A)k | 算术右移 |
SHR k,D | D<—D>>(L)k | 逻辑右移 |
hello.c中用到的地方有:
获取栈空间:
图3.3.3.3 获取栈空间
根据argv首地址获得argv[1]和argv[2]:
图3.3.3.4 获取argv[1]和argv[2]
循环中i++:
图3.3.3.5 i++
3.3.4 控制转移
a) 跳转指令
指令 | 同义名 | 跳转条件 | 描述 |
---|---|---|---|
jmp Label | 1 | 直接跳转 | |
jmp *Operand | 1 | 间接跳转 | |
je Label | jz | ZF | 相等/零 |
jne Label | jnz | ~ZF | 不相等/非零 |
js Label | SF | 负数 | |
jns Label | ~SF | 非负数 | |
jg Label | jnle | (SF^OF)&ZF | 大于(有符号>) |
jge Label | jnl | ~(SF^OF) | 大于或等于(有符号>=) |
jl Label | jnge | SF^OF | 小于(有符号<) |
jle Label | jng | (SF^OF)|ZF | 小于或等于(有符号<=) |
ja Label | jnbe | CF&ZF | 超过(无符号>) |
jae Label | jnb | ~CF | 超过或相等(无符号>=) |
jb Label | jnae | CF | 低于(无符号<) |
jbe Label | jna | CF&ZF | 低于或相等(无符号<=) |
hello.s中用到跳转指令的部分:
比较argc与3如果相等则执行循环,不等则打印提示信息退出程序:
图3.3.4.1 跳转指令(1)
argc与3不等的情况下:
图3.3.4.2 跳转指令(2)
循环体中(.L4为循环体内部语句),i<=9则执行.L4:
图3.3.4.3 跳转指令(3)
a) 转移控制
指令 | 描述 |
---|---|
call Label | 过程调用 |
call *Operand | 过程调用 |
ret | 从过程调用中返回 |
call函数过程调用并将下一条指令压栈,hello.s中用到转移控制的部分:
如果argc不等于3,将输出提示信息并执行exit函数退出程序:
图3.3.4.4控制转移(1)
循环体调用printf函数和sleep函数:
图3.3.4.5控制转移(2)
循环结束后执行getchar函数:
图3.3.4.6控制转移(3)
3.3.5 函数操作
hello.c中的函数:
a) int main(int argc, char *argv[])
参数传递与函数调用:内核执行c程序时调用特殊的启动例程,并将启动例程作为程序的起始地址,从内核中获取命令行参数和环境变量地址,执行main函数。
函数退出:hello.c中main函数有两个出口,第一个是当命令行参数数量不为3时输出提示信息并调用exit(1)退出main函数;第二个是命令行参数数量为3执行循环和getchar函数后return 0的方式退出函数。
函数栈帧结构的分配与释放:main函数通过pushq %rbp、movq %rsp, %rbp、subq $32, %rsp 为函数分配栈空间,如果是通过exit函数结束main函数则不会释放内存,会造成内存泄露,但是程序如果通过return正常返回则是由指令leave即mov %rbp,%rsp,pop %rbp恢复栈空间。
b) exit():
参数传递与函数调用:在hello.c中设置%edi值为0表示赋给exit函数第一个变量,然后通过call函数调用exit()。
c) printf()
参数传递与函数调用:printf参数根据字符串中的输出占位符数量来决定的。
hello.s中调用printf函数函数如下图:
图3.3.5.1 printf函数调用
图3.3.5.2 puts函数调用
如果printf函数有其他参数时,main函数按照寄存器表示参数的顺序为printf构造参数然后通过calll指令调用printf:
参数 | 1 | 2 | 3 | 4 | 5 | 6 | 其余 |
---|---|---|---|---|---|---|---|
地址 | %rdi | %rsi | %rdx | %rcx | %r8 | %r9 | 被调用函数的栈帧 |
如果printf只有字符串作为参数则main函数设置%rdi(%edi)为格式化字符串地址,通过call函数调用puts函数。
函数返回:printf()函数返回值为printf打印的字符数。puts()函数执行成功返回非负数,执行失败返回EOF。
d) sleep()
图3.3.5.3 sleep函数调用
参数传递与函数调用:main函数设置第一个参数%edi为sleepsecs。通过call指令调用sleep()函数。
函数返回:若进程/线程挂起到参数所指定的时间则返回0,若有信号中断则返回剩余秒数。
e) getchar()
图3.3.5.4 getchar函数调用
参数传递与函数调用:main函数通过call指令调用getchar()函数,且getchar()函数无参数。
函数返回:getchar()函数返回值类型为int,如果成功返回用户输入的ASCII码,出错返回-1。
3.3.6 类型转换
类型转换分为显示和隐式。
隐式转换:隐式转换就是系统默认的、不需要加以声明就可以进行的转换数据类型自动提升,数据类型提升顺序为:
byte,short,char -->int -->long -->float -->double
显示转换:程序通过强制类型转换运算符将某类型数据转换为另一种类型。
hello中存在隐式转换。即:
图3.3.6.1 hello.c中隐式转换
int类型变量赋值float或double类型常量会发生隐式转换,根据向下取整的原则,系统会将整数部分赋值给变量。因此sleepsecs值为2。
3.4本章小结
本章系统阐述了编译器将预处理文本文件hello.i翻译为文本文件hello.s的具体操作,主要就汇编语言伪指令、数据类型、汇编语言操作、控制转移,函数操作、类型转换六方面针对hello.s中各部分做出相应的解释说明。
第4章汇编
4.1汇编的概念与作用
概念:把汇编语言翻译成机器语言的过程称为汇编。
作用:汇编器(as)将hello.s翻译成机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在二进制目标文件hello.o中。
4.2在Ubuntu下汇编的命令
Linux终端下进入hello.c所在文件,输入指令gcc –c –o hello.o hello.s,按下回车即可。
图4.2.1 生成hello.o
4.3可重定位目标elf格式
首先使用readelf –a hello.o > hello.elf 生成hello.o文件的ELF格式。分析其组成各部分:
a) ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的头目(entry)。hello.elf 的ELF头如下:
图4.3.1 ELF头
a) 节头部表
节头部表记录了各节名称、类型、地址、偏移量、大小、全体大小、旗标、连接、信息、对齐信息。hello.elf节头部表如下:
图4.3.2 节头部表
a) 重定位节
.rela.text记录了一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。
在hello.o的重定位节中包含了main函数调用的puts、exit、printf、sleep、getchar函数以及全局变量sleepsecs,还有.rodata节(包含prnitf的格式串)的偏移量、信息、类型、符号值、符号名称及加数。rela.eh_frame记录了.text的信息。hello.elf的重定位节如下:
图4.3.3 重定位节
对hello.o进行重定位项目分析前应当首先理解重定位条目的结构及重定位方法。重定位条目被定义为如下的结构体:
1. typedef struct{
2. long offset; //偏移量 8字节
3. long type:32, //重定位类型 信息的后4个字节
4. symbol:32; //在符号表中的偏移量 信息的前4个字节
5. long addend; //计算重定位位置的辅助信息 8字节
6. }Elf64_Rela;
ELF定义了32种不同的重定位类型,针对hello.o中出现的两个:
R X86_ 64 PC32。 重定位-一个使用32位PC相对地址的引用。一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址(如call指令的目标),PC值通常是下一条指令在内存中的地址。
R X86_ 64 _32。 重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
针对不同的类型寻址算法也不同,如下:
图4.3.4 寻址算法
对hello.o中的puts函数进行重定位PC的相对引用分析:
定义puts的重定位条目为r,则其信息为:
1. {
2. r.offset = 0x1b
3. r.symbol = puts
4. r.type = R_X86_64_PC32
5. r.addend = -0x4
6. }
利用objdump获得hello.o的反汇编代码,可得出其占位符位置为:
图4.3.5 puts占位符地址
链接过程中链接器可以确定ADDR(s) 和 ADRR(r.symble),利用以下公式:
1. refptr = s + r.offset;
2. refaddr = ADDR(s) + r.offset;
3. *refptr = (unsigned)(ADDR(r.symbol) + r.addend - refaddr);
可以得出*refptr为0x**,执行这条指令时,CPU会执行以下的步骤:
-
将PC压入栈中;
-
PC <— PC+0x**即可获得puts函数的地址,成功执行。
对hello.o中的.rodata进行重定位PC的绝对引用分析:
定义其重定位条目为r,则其信息为:
1. {
2. r.offset = 0x16
3. r.symbol = .rodata
4. r.type = R_X86_64_32
5. r.addend = 0x0
6. }
这些字段告诉链接器要修改从偏移量0x16开始的绝对引用,这样会在运行时指向.rodata+0x0的位置。利用以下公式:
1. refptr = s + r.offset;
2. *refptr = (unsigned)(ADDR(r.symbol) + r.addend);
可以链接器链接时获得refptr,修改偏移量0x16处的占位符为refptr即可绝对引用,获得printf格式串的地址。
以上分别举了重定位PC相对引用的实例和重定位绝对引用的实例,其他重定位条目情况均相似。
a) 符号表
.symtab存放着程序中定义和引用函数和全局变量的信息。且不包含局部变量的条目。hello.elf的.symtab表如下:
图4.3.6 .symtab表
4.4Hello.o的结果解析
利用objdump -d -r hello.o > hello_o_asm.txt生成hello.o对应的反汇编文件,经过与hello.s比较,针对以下几个方面阐述两者的异同之处:
a) 文件内容构成
hello_o_asm.txt中只有对文件最简单的描述,记录了文件格式和.text代码段;而hello.s中有对文件的描述,全局变量的完整描述(包括.type .size .align 大小及数据类型)以及.rodata只读数据段。
两者均包含main函数的汇编代码,但是区别在于hello.s的汇编代码是由一段段的语句构成,同时声明了程序起始位置及其基本信息等;而hello_o_asm.txt则是由一整块的代码构成,除需要链接后才能确定的地址(此处用空白占位符填充),代码包含有完整的跳转逻辑和函数调用等。
图4.4.1 文件内容构成不同举例
b) 分支转移
hello_o_asm.txt包含了由操作数和操作码构成的机器语言,跳转指令中地址为已确定的实际指令地址(因为函数内部跳转无需通过链接确定);hello.s主要使用通过使用例如.L0、.L1等的助记符表示的段完成内部跳转及函数条用的逻辑。
图4.4.2 分支转移不同
a) 函数调用
hello_o_asm.txt文件中,call地址后为占位符(4个字节的0),指向的是下一条地址的位置,原因是库函数调用需要通过链接时重定位才能确定地址;而hello.s中的函数调用直接是call+函数名表示。
图4.4.3 函数调用不同
a) 数据访问方式
hello_o_asm.txt文件中,对.rodata中printf的格式串的访问需要通过链接时重定位的绝对引用确定地址,因此在汇编代码相应位置仍为占位符表示,对.data中已初始化的全局变量sleepsecs为0x0+%rip的方式访问;hello.s中访问方式为sleepsecs+%rip(此处的sleepsecs表示的是段名称而不是变量本身),格式串则需要通过助记符.LC0、.LC1等。两者访问参数的方式均相同,通过栈帧结构及%rbp相对寻址访问。
图4.4.4 数据访问方式不同
4.5本章小结
本章通过对汇编后产生的hello.o的可重定位的ELF格式的考察、对重定位项目的举例分析以及对反汇编文件与hello.s的对比,从原理层次了解了汇编这一过程实现的变化。
第五章链接
5.1链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合称为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
作用:链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。早期计算机系统中链接时手动执行的,在现代系统中,链接器由链接器自动执行。链接器使得分离编译成为可能。开发过程中无需将大型的应用程序组织委员一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。
5.2在Ubuntu下链接的命令
Linux终端下进入hello.o所在文件,输入指令ld -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
/usr/lib/gcc/x86_64-linux-gnu/5/crtbegin.o
/usr/lib/gcc/x86_64-linux-gnu/5/crtend.o
/usr/lib/x86_64-linux-gnu/crtn.o
hello.o -lc -z relro -o hello,按下回车即可。
图5.2.1 链接生成可执行目标文件
5.3可执行目标文件hello的格式
使用readelf -a hello > hello_out.elf执行获得包含hello的ELF格式的文件。
节头部表中包含了各段的基本信息,包括名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐等信息:
图5.3.1 可执行目标文件ELF(1)
图5.3.2 可执行目标文件ELF(2)
5.4hello的虚拟地址空间
hello是典型的ELF可执行文件,其中信息为:
图5.4.1 可执行文件ELF信息
解析程序头部表对应内容及其含义:
a) 程序头部表对应的数据结构为(此处解析64位程序且主要针对hello出现的部分作出解释):
1. struct Elf64_Phdr
2. {
3. Elf64_Word p_type; /* Segment type */
4. Elf64_Word p_flags; /* Segment flags */
5. Elf64_Off p_offset; /* Segment file offset */
6. Elf64_Addr p_vaddr; /* Segment virtual address */
7. Elf64_Addr p_paddr; /* Segment physical address */
8. Elf64_Xword p_filesz; /* Segment size in file */
9. Elf64_Xword p_memsz; /* Segment size in memory */
10. Elf64_Xword p_align; /* Segment alignment */
11. };
p_type:描述段的种类,指明程序头所描述的内存段的类型、或如何解析程序头的信息,取值如下:
PT_LOAD = 1: 该段是一个可装载的内存段;
PT_DYNAMIC = 2: 该段描述的是动态链接信息;
PT_INTERP = 3: 该段描述的是一个以"\0"结尾的字符串,这个字符串是一个ELF解析器的路径;
PT_NOTE = 4: 该段描述一个以"\n"结尾的字符串,这个字符串包含一些附加的信息;
PT_PHDR = 6: 此段表示的是其自身所在的程序头部表在文件或内存中的位置和大小;
PT_GNU_EH_FRAME = 0x6474e550:描述eh_frame_hdr段;
PT_GNU_STACK = 0x6474e551:表示栈的可执行性;
PT_GNU_RELRO = 0x6474e552:表明该段在重定位后设置为只读属性。
p_offset指明该段中内容在文件中的位置,即段中内容的起始位置相对于文件开头处的偏移量;
p_vaddr:指明该段中内容的起始位置在进程地址空间中的虚拟地址;
p_paddr:明该段中内容的起始位置在进程地址空间中的物理地址;
p_filesz:指明该段中内容在文件中的大小,也可以是0;单位是字节;
p_memsz:该字段指明该段中内容在内存镜像中的大小,也可以是0;单位是字节;
p_flags:该字段指明该段中内容的属性;取值如下:
PF_W = (1 << 1) :表示该段是可写的;
PF_R = (1 << 2) :指明该段是可读的;
p_align:指明该段中内容如何在内存和文件中对齐;对于可装载的段来说,其p_vaddr和p_offset的值至少要向内存页面大小对齐;如果值为0或1,则表明没有对齐要求,否则,p_align应该是一个正整数,并且是2的幂次数;p_vaddr和p_offset在对p_align取模后应该相等;
b) 使用edb加载hello查看虚拟地址空间信息
edb加载hello可以从Data Dump中查看虚拟地址空间,程序的虚拟地址空间为0x00000000004000000-0x0000000000401000,如下图:
图5.4.2 虚拟地址空间
首先查看hello_out.elf中的程序头部分,如下图:
图5.4.3 hello_out.elf程序头部分
对应Data Dump中各段映射关系分别为:
图5.4.4 PHDR部分
此处PHDR表示该段具有读/执行权限,表示自身所在的程序头部表在内存中的位置为内存起始位置0x400000偏移0x40字节处、大小为0x1c0字节。
图5.4.5 INTERP部分
此处INTERP表示该段具有读权限,位于内存起始位置0x400000偏移0x200字节处,大小为0x1c个字节,记录了程序所用ELF解析器(动态链接器)的位置位于: /lib64/ld-linux-x86-64.so.2。
图5.4.6 LOAD代码段
此处LOAD表示第一个段(代码段)有读/执行访问权限,开始于内存地址0x400000处,总共内存大小是0x838个字节,并且被初始化为可执行目标文件的头0x838字节,其中其中包括ELF头、程序头部表以及.init、.text、.rodata字节。
图5.4.7 LOAD(代码段)
此处的LOAD表示第二个段(数据段)有读写权限,开始于内存地址0x600e10地址处,总的内存大小为0x250个字节,并用从目标文件中偏移0xe10处开始的.data中的0x24c个字节初始化。该段中剩下的4个字节对应于初始时将被初始化为0的.bss数据。
图5.4.8 NOTE部分
此处NOTE表示该段位于内存起始位置0x400000偏移0x21c字节处,大小为0x20个字节,该段是以‘\0’结尾的字符串,包含一些附加信息。
图5.4.9 GUN_RELOAD部分
此处为GNU_RELRO表示该段在重定位后设置为只读属性。
图5.4.10 DYNAMIC部分
该段描述动态链接信息。
5.5链接的重定位过程分析
利用指令objdump –d –r hello > hello_out_asm.txt 生成反汇编文件。对比于hello.o的反汇编文件,对比可得知不同点:
a) 文件内容
hello反汇编文件中包含Disassembly of section .init .plt .plt.got .text .fini,而hello.o反汇编文件只有Disassembly of section .text。对hello中的段做解释说明:
段名称 | 含义 |
---|---|
.init | 程序初始化代码 |
.plt | 位于代码段中,为动态链接中的过程链接表 |
.plt.got | 动态链接中过程链接表PLT和全局偏移量表GOT的间接调转 |
.fini | 程序结束执行代码 |
b) 函数调用
hello.o反汇编文件中,call地址后为占位符(4个字节的0);而hello在生成过程中使用了动态链接共享库,函数调用时用到了延时绑定机制。以puts为例,简述其链接过程:
-
puts第一次被调用时程序从过程链接表PLT中进入其对应的条目;
-
第一条PLT指令通过全局偏移量表GOT中对应条目进行间接跳转,初始时每个GOT条目都指向它对应的PLT条目的第二条指令,这个简单跳转把控制传送回对应PLT条目的下一条指令;
-
把puts函数压入栈中之后,对应PLT条目调回PLT[0];
-
PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]间接跳转进动态链接器中。动态链接器使用两个栈条目确定puts的运行时位置,用这个地址重写puts对应的GOT条目,再把控制传回给puts。
第二次调用的过程:
-
puts被调用时程序从过程链表PLT中进入对应的条目;
-
通过对应GOT条目的间接跳转直接会将控制转移到puts。
c) 数据访问hello.o反汇编文件中,对.rodata中printf的格式串的访问需要通过链接时重定位的绝对引用确定地址,因此在汇编代码相应位置仍为占位符表示,对.data中已初始化的全局变量sleepsecs为0x0+%rip的方式访问;而hello反汇编文件中对应全局变量已通过重定位绝对引用被替换为固定地址。
5.6hello的执行流程
加载程序 | ld-2.23.so!_dl_start |
---|---|
ld-2.23.so!_dl_init | |
LinkAddress!_start | |
ld-2.23.so!_libc_start_main | |
ld-2.23.so!_cxa_atexit | |
LinkAddress!_libc_csu.init | |
ld-2.23.so!_setjmp | |
运行 | LinkAddress!main |
程序终止 | ld-2.23.so!exit |
5.7Hello的动态链接分析
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
根据hello ELF文件可知,GOT起始表位置为0x601000,如图:
图5.5.1 GOT表位置
在调用dl_init之前0x601008后的16个字节均为0:
图5.5.2 GOT表初始前内容
调用_start之后发生改变,0x601008后的两个8个字节分别变为:0x7fb06087e168、0x7fb06066e870,其中GOT[O](对应0x600e28)和GOT[1](对应0x7fb06087e168)包含动态链接器在解析函数地址时会使用的信息。GOT[2](对应0x7fb06066e870)是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,改变后的GOT表如下:
图5.5.3 GOT表初始后内容
GOT[2]对应部分是共享库模块的入口点,如下:
图5.5.4共享库模块入口点
举例puts函数在调用puts函数前对应GOT条目指向其对应的PLT条目的第二条指令,如图[email protected]指令跳转的地址:
图5.5.4 puts&plt函数
图5.5.5 调用puts函数前(链接前)PLT函数
可以看出其对应GOT条目初始时指向其PLT条目的第二条指令的地址。puts函数执行后在查看此处地址:
可以看出其已经动态链接,GOT条目已经改变。
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8本章小结
本章了解了链接的概念作用,分析可执行文件hello的ELF格式及其虚拟地址空间,同时通过实例分析了重定位过程、加载以及运行时函数调用顺序以及动态链接过程,深入理解链接和重定位的过程。
第6章hello进程管理
6.1进程的概念与作用
概念:进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量、以及打开文件描述符的集合。
作用:进程给应用程序提供的关键抽象有两种:
a) 一个独立的逻辑控制流,提供一个假象,程序独占地使用处理器。
b) 一个私有的地址空间,提供一个假象,程序在独占地使用系统内存。
6.2简述壳Shell-bash的作用与处理流程
shell作为UNIX的一个重要组成部分,是它的外壳,也是用户于UNIX系统交互作用界面。Shell是一个命令解释程序,也是一种程序设计语言。
\1. 读入命令行、注册相应的信号处理程序、初始化进程组。
\2. 通过paraseline函数解释命令行,如果是内置命令则直接执行,否则阻塞信号后创建相应子进程,在子进程中解除阻塞,将子进程单独设置为一个进程组,在新的进程组中执行子进程。父进程中增加作业后解除阻塞。如果是前台作业则等待其变为非前台程序,如果是后台程序则打印作业信息。
6.3Hello的fork进程创建过程
首先了解进程的创建过程:父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时。子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的id。
fork后调用一次返回两次,在父进程中fork会返回子进程的PID,在子进程中fork会返回0;父进程与子进程是并发运行的独立进程。内核能够以任何方式交替执行他们逻辑控制流中的指令。
hello的fork进程创建过程为:系统进程创建hello子进程然后调用waitpid()函数知道hello子进程结束,程序进程图如下:
图6.3.1 hello fork过程
6.4Hello的execve过程
系统为hello fork子进程之后,子进程调用execve函数加载并运行可执行目标文件hello,并且带参数列表argv和环境变量列表envp,并将控制传递给main函数,以下是其详细过程:
加载器将hello中的代码和数据从磁盘复制到内存中,它创建类似于Linux x86-64运行的虚拟内存映像,如下图所示:
图6.4.1 Linux x86-64运行的虚拟内存映像
加载器在程序头部表的引导下将hello的片复制到代码段和数据段。接下来加载器跳转到程序的入口点——_start函数的地址,_start函数调用系统启动函数__libc_start_main(定义在libc.so中),该启动函数初始化执行环境,处理main函数返回值,在需要的时候把控制传回给内核。
图6.4.2 hello栈的组织结构
6.5Hello的进程执行
首先了解进程执行中逻辑控制流、并发流、用户模式和内核模式、上下文切换等概念:
a) 逻辑控制流
在调试器单步执行程序时,会发现一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC的值的序列叫做逻辑控制流。
b) 并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地执行。
多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念被称为多任务。
一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫时间分片。
c) 用户模式和内核模式
处理器通过某个控制寄存器中的一个模式位来提供限制一个应用可以执行的指令以及它可以访问的地址空间范围的功能。该寄存器描述了当前进程享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存为止;没有设置模式位时,进程就运行在用户模式中。用户模式的进程不允许和执行特权指令、也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。
d) 上下文切换
内核为每个进程维持一个上下文,上下文就是内核重新启动的一个被强占的进程所需的状态。由包括通用目的寄存器、浮点寄存器、程序计数器、用户站、状态寄存器、内核栈和各种内核数据结构。
上下文切换的机制:
-
保存当前进程的上下文;
-
恢复某个先前被抢占的进程被保存的上下文;
-
将控制传递给这个新恢复的进程。
接下来阐述进程调度的概念及过程、用户态和核心态的转换:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这个决策就叫做调度,是由内核中称为调度器的代码处理的。在内和调度了一个新的进程运行后,它就抢占当前进程,并使用上文所述的上下文切换的机制将控制转移到新的进程。内核代表的用户执行系统调用时,可能会发生上下文切换;中断也有可能引发上下文切换。
图6.5.1上下文切换
通过上图所示的内核模式用户模式的切换描述用户态核心态转换的过程,在切换的第一部分中,内核代表进程A在内核模式下执行指令。然后在某一时刻,它开始代表进程B(仍然是内核模式下)执行指令。在切换之后,内核代表进程B在用户模式下执行指令。随后,进程B在用户模式下运行一会儿,直到磁盘发出一个中断信号,表示数据已经从磁盘传送到了内存。内核判定进程B已经运行了足够长的时间,就执行一个从进程B到进程A的上下文切换,将控制返回给进程A中紧随在系统调用read之后的那条指令。进程A继续运行,直到下一次异常发生,依此类推。
6.6hello的异常与信号处理
hello执行过程中出现的异常种类可能会有:中断、陷阱、故障、终止。
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断的异常处理程序被称为中断处理程序。
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
信号允许进程和内核中断其他进程。每种信号都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程是不可见的。信号提供一种机制,通知用户进程发生了这些异常。例如在hello运行过程中键入回车,Ctrl-Z,Ctrl-C等,如下图:
图6.6.1 运行过程键入Ctrl-Z
在程序运行过程中键入Ctrl-Z,会导致内核发送SIGTSTP信号给hello,同时发送SIGCHLD给hello的父进程。
图6.6.1 运行过程键入Ctrl-Z后执行ps
键入Ctrl-Z后执行ps显示当前进程数量及内容,其中包含被暂停的hello进程,进程PID为15512.
图6.6.1 运行过程键入Ctrl-Z后执行jobs
键入Ctrl-Z后执行jobs显示当前暂停的进程,其中包含hello进程。
图6.6.1 运行过程键入Ctrl-Z后执行pstree
执行pstree显示当前进程树。
图6.6.1 运行过程键入Ctrl-Z后执行fg
执行fg命令恢复前台作业hello。
图6.6.1 运行过程键入Ctrl-Z后执行kill
执行kill命令杀死进程。
图6.6.1 运行过程键入Ctrl-C
在hello执行过程中键入Ctrl-C终止hello进程。
图6.6.1 运行过程键入Ctrl-Z后键入空格
在hello执行过程中键入回车键。
6.7本章小结
本章从进程的角度分别描述了hello子进程fork和execve过程,并针对execve过程中虚拟内存映像以及栈组织结构等作出说明。同时了解了逻辑控制流中内核的调度及上下文切换等机制。阐述了Shell和Bash运行的处理流程以及hello执行过程中可能引发的异常和信号处理。
第7章hello的存储管理
7.1hello的存储器地址空间
逻辑地址空间是由段地址和偏移地址构成的。
例如:23:8048000 段寄存器(CS等16位):偏移地址(16/32/64);
实模式下:逻辑地址CS:EA —>物理地址CS*16+EA;
保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址, 段地址+偏移地址=线性地址。
线性空间地址为非负整数地址的有序集合,例如{0,1,2,3…}。
虚拟地址空间为N = 2n 个虚拟地址的集合,例如{0,1,2,3,….,N-1}。
物理地址空间为M = 2m 个物理地址的集合,例如{0,1,2,3,….,M-1}。物理地址是真实的物理内存的地址。
Intel采用段页式存储管理(通过MMU)实现:
·段式管理:逻辑地址—>线性地址==虚拟地址;
·页式管理:虚拟地址—>物理地址。
以hello中的puts调用为例:mov $0x400714,%edi callq 4004a0,$0x400714为puts输出字符串逻辑地址中的偏移地址,需要经过段地址到线性地址的转换变为虚拟地址,然后通过MMU转换为物理地址,才能找到对应物理内存。
7.2Intel逻辑地址到线性地址的变换-段式管理
图7.2.1段式寄存器的含义
段式寄存器(16位)用于存放段选择符:CS(代码段)是指程序代码所在段;SS(栈段)是指栈区所在段;DS(数据段)是指全局静态数据所在段;其他三个段寄存器ES、GS和FS可指向任意数据段。
段选择符中各字段含义为:
图7.2.2 段选择符
其中TI表示描述符表,TI=0则为全局描述符表;TI=1则为局部描述符表。RPL表示环保护,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,中间环留给中间软件使用。高13位表示用来确定当前使用的段描述符在描述符表中的位置。
逻辑地址向线性地址转换的过程中被选中的描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址,过程如下图(注意:GDT首址或LDT首址都在用户不可见寄存器中):
图7.2.3 逻辑地址向线性地址转换过程
7.3Hello的线性地址到物理地址的变换-页式管理
虚拟内存概念:虚拟内存是系统对主存的抽象概念,是硬件异常、硬件地址翻译、主存、磁盘文件和内存文件的完美交互。为每个进程提供了一个大的、一致的和私有的地址空间。
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。虚拟页则是虚拟内存被分割为固定大小的块。物理内存被分割为物理页,大小与虚拟页大小相同。
图7.3.1虚拟页物理页缓存关系
页表是一个页表条目的数组,将虚拟页地址映射到物理页地址。
图7.3.2 虚拟页映射物理页
地址翻译中需要了解虚拟地址、物理地址的组成部分及其他基本参数,如下:
图7.3.3 虚拟地址物理地址组成部分及参数
地址翻译可简化为以下流程:
图7.3.4 地址翻译过程
形式上来说,地址翻译是一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)中元素的映射。CPU中存在一个控制寄存器为页表基址寄存器指向当前页表。n位的虚拟地址包含着p位的虚拟页面偏移和(n-p)位的虚拟页号。MMU利用v*n来选择适当的PTE,将页表条目中的物理页号和虚拟地址中的VPO串联起来就得到相对应的物理地址。
页命中时CPU硬件执行的步骤为:
-
处理器生成虚拟地址,并传给MMU。
-
MMU生成PTE地址,并从高速缓存/主存中请求得到它。
-
高速缓存/主存向MMU返回PTE。
-
MMU构造物理地址并把它传送给高速缓存/主存。
-
高速缓存/主存泛会所请求的数据字给处理器。
图7.3.5页命中时CPU硬件执行步骤
缺页时CPU硬件执行步骤为:
-
与页命中1)到3)相同。
-
PTE中有效位为0,MMU触发一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
-
却也处理程序确定出物理内存的牺牲页,如果这个页面已经被修改了,则把他换出物理磁盘。
-
缺页处理程序页面调入新的页面,并更新内存中的PTE。
-
缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中。主存会将所请求的字返回给处理器。
图7.3.6 页不命中时CPU硬件执行步骤
7.4TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。其组成部分如下图:
图7.4.1 TLB组成
用于组选择和行匹配的索引和标记字段是从虚拟地址的虚拟页号中提取出来的。如果TLB有T=2^t个组,那么TLB索引是由v*n的t个最低位组成的,而TLB标记是由v*n中剩余的位组成的。
TLB中所有的地址翻译步骤都是在芯片上的MMU执行的,因此非常快。当TLB命中时,其执行步骤为:
-
CPU上产生一个虚拟地址。
-
(2) 和 3))MMU从TLB中取出相应的PTE。
-
MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
-
高速缓存/主存将所请求的数据字返回给CPU。
其对应的操作图为:
图7.4.2 TLB命中
TLB不命中时,MMU需要从L1缓存取出相应的PTE,存于TLB之中,可能会覆盖已存在条目。其操作图为:
图7.4.3 TLB不命中
使用四级页表的地址翻译,虚拟地址被划分为4个v*n和1个VPO。每个v*ni都是一个到i级页表的索引,其中1<=i<=4.第j级页表中的每个PTE,1<=j<=3,都是指向第j+1级的每个页表的基址。第4级也表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问4个PTE。下图为使用Core i7的4级页表的地址翻译:
图7.4.5 多级页表地址翻译
7.5三级Cache支持下的物理内存访问
如图为Core i7的内存系统:
图7.5.1 Core i7内存系统
针对物理内存访问,主要对各类高速缓存存储器的读写策略做出说明:
当CPU执行一条读内存字w的指令,它向L1高速缓存请求这个字。如果L1高速缓存由w的一个缓存的副本,那么就得到L1的高速缓存命中,高速缓存会很快抽取出w并返回给CPU。否则就是缓存不命中,当L1高速缓存向主存请求包含w的块的一个副本时,CPU必须等待。当被请求的块最终从内存到达时,L1高速缓存将这个快存放在他的一个高速缓存行里,从被缓存的块中抽取字w,然后返回给CPU。总体来看,高速缓存确定一个请求是否命中,然后抽取出被请求的字的过程,分为三步,(1)组选择、(2)行匹配、(3)字抽取。
-
直接映射高速缓存读策略:
直接映射高速缓存E=1,即每组只有一行。组选择是通过组索引位标识组。高速缓存从w的地址中间抽取出s个组索引位,这些位被解释为一个对应于一个组号的无符号整数,来进行组索引。行匹配中,确定了某个组i,接下来需要确定是否有字w的一个副本存储在组i包含的一个高速缓存行里,因为直接映射高速缓存只有一行,如果有效位为1且标志位相同则缓存命中,根据块偏移位即可查找到对应字的地址并取出;若有效位为1但标志位不同则冲突不命中,有效位为0则为冷不命中,此时都需要从存储器层次结构下一层取出被请求的块,然后将新的块存储在组索引位指示的组中的一个高速缓存行中。
-
组相联高速缓存读策略:
组相联高速缓存每个组都会保存多余一个的高速缓存行,组选择与直接映射高速缓存的组选择一样,通过组索引位标识组。行匹配时需要找遍组中所有行,找到标记位有效位均相同的一行则缓存命中;如果CPU请求的字不在组的任何一行中,则缓存不命中,选择替换时如果存在空行选择空行,如果不存在空行则通过替换策略替换其中一行。
-
全相联高速缓存读策略:
全相联高速缓存只包含一个组,其行匹配和字选择与组相联高速缓存中一样,区别主要是规模大小的问题。
写策略:分为两种,直写和写回。
直写是立即将w的高速缓存块写回到紧接着的低一层中。虽然简单,但是只写的缺点是每次写都会引起总线流量。
写回尽可能的推迟更新,只有当替换算法要驱逐这个更新过的块时,才把它写回到紧接着的第一次层中,由于局部性,写回能显著减少总线流量,但增加了复杂性。处理写不命中有两种方法一种为写分配,加载相应的的低一层的块到高速缓存中,然后更新这个高速缓存块。另一种方法为非写分配,避开高速缓存,直接把这个字写到低一层中,直写高速缓存通常是非写分配的,写回高速缓存通常是写分配的。
7.6hello进程fork时的内存映射
首先了解共享对象在虚拟内存中的应用:
一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟内存的其他进程而言,也是可见的。而且,这些变化也会反映在磁盘上的原始对象中。
另一方面,对于一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。一个映射到共享对象的虚拟内存区域叫做共享区域。类似地,也有私有区域。下图为共享对象使用实例:
图7.6.1 共享对象
其次,关注写时复制这一概念:
私有对象使用一种叫写时复制来映射至虚拟内存中,多个进程可将一个私有对象映射到其内存不同区域,共享该对象同一物理副本对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时复制。只要没有进程试图写它自己的私有区域,它们就可以继续共享物理内存中对象的一个单独副本。然而,只要有一个进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。
当故障处理程序注意到保护异常是由于进程试图写私有的写时复制区域中的一个页面而引起的,它就会在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新的副本,然后恢复这个页面的可写权限。下图为写时复制的示例:
图7.6.2 写时复制
当fork函数被系统调用时,内核会为hello创建子进程,同时会创建各种数据结构并分配给hello唯一的PID。为了给hello创建虚拟内存,内核创建了当前进程的mm_struct、区域结构和样表的原样副本,并将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为写时复制。
7.7hello进程execve时的内存映射
execve函数在hello进程中加载并运行hello,主要步骤如下:
-
删除已存在的用户区域。
-
映射hello私有区域。
-
映射共享区域。
-
设置程序计数器PC。
下一次调度hello,将从入口点开始执行。Linux根据需要换入代码和数据页面。
加载器映射用户地址空间区域如下:
图7.7.1 加载器映射用户地址空间区域
7.8缺页故障与缺页中断处理
下图为Linux组织虚拟内存的结构:
图7.8.1 Linux组织虚拟内存的结构
缺页异常及其处理过程:如果DRAM缓存不命中称为缺页。当地址翻译硬件从内存中读对应PTE时有效位为0则表明该页未被缓存,触发缺页异常。缺页异常调用内核中的缺页异常处理程序。
缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较,如果指令不合法,缺页处理程序就触发一个段错误、终止进程。
缺页处理程序检查试图访问的内存是否合法,如果不合法则触发保护异常终止此进程。
缺页处理程序确认引起异常的是合法的虚拟地址和操作,则选择一个牺牲页,如果牺牲页中内容被修改,内核会将其先复制回磁盘。无论是否被修改,牺牲页的页表条目均会被内核修改。接下来内核从磁盘复制需要的虚拟页到DRAM中,更新对应的页表条目,重新执行导致缺页的指令。以下为Linux缺页处理简图:
图7.8.2 Linux缺页处理
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为 一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已 分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,都要求应用显式地分配块,它们的不同之处在于由哪个实体来负责释放已分配的块。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。而自动释放未使用的已分配的块的过程叫做垃圾收集。
隐式空闲链表的堆块格式及其组织格式如下图:
图7.9.1 隐式链表堆块格式
图7.9.2 隐式链表组织格式
隐式空闲链表空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。
显式空闲链表的堆块格式如下图:
图7.9.3 显示链表组织格式
显式空闲链表有两种方式来维护一种是先进后出,另一种是地址顺序。此处不详细展开。
放置空闲块的策略有三种,分别是首次适配、下一次适配、最佳适配。
首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一.次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
7.10本章小结
本章从Linux存储器的地址空间起,阐述了Intel的段式管理和页式管理机制,以及TLB与多级页表支持下的VA到PA的转换,同时对cache支持下的物理内存访问做了说明。针对内存映射及管理,简述了hello的fork和execve内存映射,了解了缺页故障与缺页中断处理程序,对动态分配管理做了系统阐述。
第8章hello的IO管理
8.1Linux的IO设备管理方法
一个Linux文件就是一个m字节的序列。所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有输入和输出都能以一种统一且一致的方式来执行:
-
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。
-
Linux Shell创建的每个进程开始时都有三个打开的文件:标准输入、标准输出、标准错误。
-
改变当前文件的位置。对于每个打开的文件,内核保持着一个文件位置k、初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显示地设置文件的当前位置为k。
-
读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
-
关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。
8.2简述Unix IO接口及其函数
-
open()
函数原型:int open(char * filename, int flags, mode_t mode);
解析:open函数将file那么转换为一个文件描述符并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。
-
close()
函数原型:int close(int fd);
-
read()
函数原型: ssize_t read(int fd, void * buf, size_t n);
解析:read函数从描述符为fd 的当前文件位置复制最多n个字节到内存位置buf。返回值一1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
-
write()
函数原型:ssize_t write(int fd, const void * buf, size_t n);
解析:write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
8.3printf的实现分析
首先查看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. }
va_list的定义是:typedef char * va_list,因此通过调用va_start函数,获得的arg为第一个参数的地址。
vsprintf的作用是格式化。接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出,例如hello中:
printf(“Hello %s %s\n”,argv[1],argv[2]);
命令行参数为./hello 1173710217 hpy,则对应格式化后的字符串为:Hello 1173710217 hpy\n,并且i为返回的字符串长度
接下来是write函数:
1. write:
2. mov eax, _NR_write
3. mov ebx, [esp + 4]
4. mov ecx, [esp + 8]
5. int INT_VECTOR_SYS_CALL
根据代码可知内核向寄存器传递几个参数后,中断调用syscall函数。对应ebx打印输出的buf数组中第一个元素的地址,ecx是要打印输出的个数。查看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到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),实现printf格式化输出。
8.4getchar的实现分析
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. }
异步异常-键盘中断的处理:键盘中断处理是底层的硬件异常,当用户按下键盘时,内核会调用异常键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar函数read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。实现读取一个字符的功能。
8.5本章小结
本章系统的了解了Unix I/O,通过LinuxI/O设备管理方法以及Unix I/O接口及函数了解系统级I/O的底层实现机制。通过对printf和getchar函数的底层解析加深对Unix I/O以及异常中断等的了解。
结论
hello生命周期的大事件:
-
hello.c首先被经过预处理器处理,得到预处理文本文件hello.i。
-
hello.i经过编译器处理生成文本文件hello.s,包含一个汇编程序。
-
hello.s经过汇编器翻译为机器语言指令,打包为可重定位目标程序hello.o。
-
hello经过链接生成可执行目标文件hello。
-
在Linux下键入./hello 1173710217 hpy运行hello,内核为hello fork出新进程,并在新进程中execve hello程序。
-
execve 通过加载器将hello中的代码和数据从磁盘复制到内存,为其创建虚拟内存映像,加载器在程序头部表的引导下将hello的片复制到代码段和数据段,执行_start函数。
-
MMU通过页表将虚拟地址映射到对应的物理地址完成访存。
-
内核通过GOT和PLT协同工作完成共享库函数的调用。
-
hello调用函数(eg:printf),内核通过动态内存分配器为其分配内存。
-
内核通过调度完成hello和其他所有进程的上下文切换,成功运行hello。
-
shell父进程回收hello,内核删除hello进程的所有痕迹。hello——卒。
回顾hello短暂的一生,可以看出hello虽然很简单,但是却凝结着人类的智慧。自1946年第一台电子计算机问世以来,计算机技术在元件器件、硬件系统结构、软件系统、应用等方面,均有惊人进步。从两个足球场大的计算机到如今我们面前的小小的笔记本,不得不令人感叹现代计算机系统设计的精巧。
计算机系统高效有序的运行离不开底层硬件的完美契合,计算机多级存储结构、内核对进程的调度策略、动态链接的执行方式、cache替换策略、页表替换策略、异常与信号等的处理……这些无一不体现计算机底层实现的完备与优雅。同时,作为程序员,了解与学习计算机底层实现也有助于我们充分利用计算机,编写出计算机底层友好的代码,提高计算、工作的效率。
总之,hello的故事,其实才刚刚开始……
附件
-
hello.c:源代码
-
hello.i:hello.c预处理生成的文本文件。
-
hello.s:hello.i经过编译器翻译成的文本文件hello.s,含汇编语言程序。
-
hello.o:hello.s经汇编器翻译成机器语言指令打包成的可重定位目标文件
-
hello.elf:hello.o的ELF格式。
-
hello_o_asm.txt:hello.o反汇编生成的代码。
-
hello:经过hello.o链接生成的可执行目标文件。
-
hello_out.elf:hello的ELF格式。
-
hello_out_asm.txt:hello反汇编生成的代码。
-
test1.c:测试文件
-
test1.i:测试文件预处理生成的文本文件。
-
test1.s:test1.i经过编译器翻译成的文本文件。
参考文献
[1]兰德尔E.布莱恩特 大卫R.奥哈拉伦. 深入理解计算机系统(第3版).机械工业出版社. 2018.4.
[2]条件编译#ifdef的妙用详解_透彻:https://blog.****.net/qq_33658067/article/details/79443014
[3]typedef 百度百科:https://baike.baidu.com/item/typedef/9558154?fr=aladdin
[4]extern 百度百科:https://baike.baidu.com/item/extern/4443005?fr=aladdin
[5]编译 百度百科:https://baike.baidu.com/item/编译/1258343?fr=aladdin
[6]printf函数详细讲解:https://www.cnblogs.com/windpiaoxue/p/9183506.html
[7] 关于Linux 中sleep()函数说明:https://blog.****.net/fly__chen/article/details/53175301
[8] getchar()函数的返回值以及单个字符输出函数putchar:https://blog.****.net/cup160828/article/details/58067647?utm_source=blogxgz9
[9]gcc常用命令选项:https://blog.****.net/Crazy_Tengt/article/details/71699029
[10]ELF文件-段和程序头:https://blog.****.net/u011210147/article/details/54092405
[11]printf函数实现的深入剖析:https://www.cnblogs.com/pianist/p/3315801.html
[12]getchar()函数详解:http://www.aspku.com/kaifa/c/332134.html