计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 未来技术
学 号 7203610826
班 级 2036013
学 生 季世宇
指 导 教 师 刘宏伟
计算机科学与技术学院
2022年5月
hello.c是每个程序入门写的程序。本文从系统角度,展现了linux下hello程序的整个生命周期。包括从hello.c源代码,到预处理后的hello.i文件,编译后的 hello.s文件,汇编后生成hello.o文件,最后hello.o和系统相关库被链接器ld链接,生成可执行目标文件hello。shell用fork函数创建子进程,execve加载hello程序,由CPU控制逻辑流的运行、中断、上下文切换。最后进程终止并由shell回收子进程。其中包括了进程管理,存储管理以及IO管理。
关键词:计算机系统;P2P;LINUX
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 49 -
6.3 Hello的fork进程创建过程... - 50 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 57 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 58 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 59 -
7.5 三级Cache支持下的物理内存访问... - 59 -
7.6 hello进程fork时的内存映射... - 60 -
7.7 hello进程execve时的内存映射... - 60 -
第1章 概述
1.1 Hello简介
P2P: program to process,从程序到进程。在Linux上,hello.c经过cpp预处理、ccl编译、as汇编、ld链接后,程序hello.c最终生成可执行的目标程序hello。Shell通过folk()一个子进程,并在execve hello可执行文件,使得从hello.c到一个正在执行的进程。这就是P2P。
020: Zero-0 to Zero-0,从0到0。Shell用execve加载可执行文件,将其映射到虚拟内存,CPU为运行的进程分配时间片,hello运行在一条逻辑控制流,进入star point后程序开始载入物理内存,然后进入 main函数执行相关指令,在程序运行结束后,父进程会对其进行回收,内核把它从系统中清除,这时hello就由0转换为0,完成了020的过程。
1.2 环境与工具
1.2.1 硬件环境
CPU:Intel(R) Core(TM) i7-10875H CPU @ 2.30GHz
GPU:NVIDIA GeForce RTX 2060 6 GB
内存:kingston DDR4 8GB*2
硬盘:WDC PC SN730 SDBPNTY-512G-1101
1.2.2 软件环境
Windows10 64位;Ubuntu20.04.4 64位
1.2.3 开发工具
Visual Studio 2017 64位;Codeblocks 64位
gcc/ld/readif/vim/edb/objdump
1.3 中间结果
hello.c | C语言源代码文件 |
hello.i | hello.c经过预处理产生的文件 |
hello.s | hello.i经过编译产生的文件 |
hello.o | hello.s经过汇编产生的文件 |
hello | hell.o经过链接产生的可执行文件 |
o_elf.txt | hello.o的elf格式文件,生成的txt |
o_asm.txt | hello.o的反汇编,生成的txt |
out_elf.txt | hello的elf格式文件,生成的txt |
out_asm.txt | hello的反汇编,生成的txt |
1.4 本章小结
本章介绍了hello程序的P2P(Program to Process)及020(Zero-0 to Zero-0)的过程。列出此次大作业完成的硬件环境、软件环境以及开发工具。以及中间结果。
图1-1 第一章小结
第2章 预处理
2.1 预处理的概念与作用
程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
预处理器在UNIX传统中通常缩写为PP,在自动构建脚本中C预处理器被缩写为CPP的宏指代。为了不造成歧义,C++(cee-plus-plus) 经常并不是缩写为CPP,而改成CXX。
最常见的预处理是C语言和C++语言。C语言的预处理主要有三个方面的内容: 1.宏定义; 2.文件包含; 3.条件编译。 预处理命令以符号“#”开头。
ISO C和ISO C++都规定程序由源代码被翻译分为若干有序的阶段(phase),通常前几个阶段由预处理器实现。预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令) 。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译[1]。
2.2在Ubuntu下预处理的命令
图2-1 预处理命令
将hello.c文件进行预处理,-E就是进行预处理,-o是将预处理后的文件存储在hello.i文件中,hello.i文件叫做目的文件。
2.3 Hello的预处理结果解析
预处理后,我们得到了一个hello.i文件,我们可以看到这个文件增大到64.7KB。
图2-2 得到的hello.i文件及其大小
头文件展开
通过编译器找到指定的头文件,并将头文件对应的内容拷贝至源文件。
图2-3 对应的头文件内容
代码保留
在hello.i文件结尾处,我们能发现源代码的保留。图2-4 源代码的保留
去注释
打开hello.i文件(右边)中,可以看到hello.c文件中注释的内容没有显示出来。图2-5 去注释
2.4 本章小结
在本章里面,了解学习了预处理的概念和作用,并且在Linux下使用预处理指令,并且查看了预处理后hello.i的具体内容,更加深入理解了预处理的实质和作用。
其中预处理主要包括几个作用:
- 头文件展开,将源代码中#include的头文件拷贝到hello.i中。
- 去注释,会将源代码中注释掉的内容去掉。
- 宏替换,这个作用在这里没有得到体现,但实际上,在预处理后,会将源代码中宏定义的内容,替换为本来的内容。
- 条件编译,在一些#if OS #else #endif代码中,i文件中只会保留满足条件的源代码。
图2-6 第二章小结
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译(compilation , compile) 1、利用编译程序从源语言编写的源程序产生目标程序的过程。 2、用编译程序产生目标程序的动作。 编译就是把高级语言变成计算机可以识别的2进制语言,计算机只认识1和0,编译程序把人们熟悉的语言换成2进制的。 编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
在这里,编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含了一个汇编语言程序。
编译的作用:
- 词法分析:
词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。执行词法分析的程序称为词法分析程序或扫描器。
- 语法分析:
编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序。按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构。
- 代码优化:
代码优化是指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。所谓等价,是指不改变程序的运行结果。所谓有效,主要指目标代码运行时间较短,以及占用的存储空间较小。这种变换称为优化。
- 目标代码:
目标代码生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。目标代码有三种形式:
② 待装配的机器语言模块,当需要执行时,由连接装入程序把它们和某些运行程序连接起来,转换成能执行的机器语言代码;
③ 汇编语言代码,须经过汇编程序汇编后,成为可执行的机器语言代码。
- 出错处理:
如果编译过程中发现源程序有错误,编译程序应报告错误的性质和错误的发生的地点,并且将错误所造成的影响限制在尽可能小的范围内,使得源程序的其余部分能继续被编译下去,有些编译程序还能自动纠正错误,这些工作由错误处理程序完成[2]。
3.2 在Ubuntu下编译的命令
图3-1 编译命令
将预处理后的hello.i文件编译生成hello.s文件。
3.3 Hello的编译结果解析
在这一部分将按照数据、赋值、类型转换、sizeof、算术操作、逻辑/位运算、关系操作、数组/指针/结构操作、控制转移、函数操作等各方面来对hello.s进行解析。
3.3.1 数据
常量:
数字常量:在hello.c中用到的数字常量会在保存在.text段当中,会作为指令的一部分。如图。
图3-2 数字常量表示
可以看到数字常量在文本文件当中被表示为指令的一部分,用立即数来表示。
字符串常量:printf的格式串以及输出的字符串储存在.rodata(只读数据)当中,我们能在文本文件中看到。
图3-3 字符串常量表示
变量:
全局变量:在hello.c当中没有定义全局变量。
局部变量:局部变量保存在寄存器或者栈当中。
图3-4 变量argc的存储
通过分析汇编代码,可以发现argc这个局部变量保存在-20(%rbp)这个栈地址指向的位置中。
图3-5 变量i的存储
可以看到每次会对%rbp-4这个位置的值+1,然后会将它和7作比较,可以看出变量i储存在%rbp-4这个栈位置上。
3.3.2 赋值
hello.c当中对i变量进行了赋值操作,在循环开始将其赋为0。
图3-6 i的赋值语句
这里在翻译成汇编语句:直接把立即数0存在变量i的位置上。
图3-7 汇编当中赋值操作
3.3.3 类型转换
源代码中并没有出现类型强转的代码,但是调用了一个atoi()函数,该函数的功能是将字符串转换为整型。
图3-8 类型转换
这里将指针数组argv[3]中存放的字符串地址对应的字符串转换为了整型变量。编译后,翻译后的汇编即为调用atoi@PLT函数。
图3-9 atoi函数实现类型转换
3.3.4 算术操作
在源代码进行的算术操作,是对循环变量i进行++。我们可以看到对应的汇编操作是对i变量存放位置的值进行+1。
图3-10 变量i的++算数操作
3.3.5 关系操作
在hello.c当中一共有两处关系操作。
图3-11 两处关系操作
第一处是对于变量argc的判断,当等于4的时候将进行条件跳转。第二处是在for循环中对于循环变量i是否达到循环次数的判断,当循环变量i大于等于7的时候将进行条件跳转。
3.3.6 数组/指针/结构操作
在hello.c当中,在main函数当中传入了一个指针数组*argv[]。
图3-12 指针数组*argv[]
在argv数组中,argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别表示两个字符串,argv[3]其实也表示一个字符串,只不过会在用的时候转换为整型。
char* 数据类型占8个字节,所以我们可以在汇编代码当中找到储存数组的地方。
图3-13 数组操作
对比原函数可知通过M[%rbp-32+16]、M[%rbp-32+8]和M[%rbp-32+8],分别得到argv[2]、argv[1]和argv[3]三个字符串的首地址对应的值。
3.3.7 函数操作
main函数:
- 参数传递:传入参数argc和argv[],分别用寄存器%rdi和%rsi存储。
- 函数调用:被系统启动函数调用。
- 函数返回:设置%eax为0并且返回,对应return 0 。
- 源代码以及对应汇编代码:
图3-14 main函数
printf函数(1):
- 参数传递:传入了字符串首地址。
- 函数调用:在if(argc!=4)判断成立后调用。
- 源代码以及对应汇编代码:
图3-14 printf函数(1)
printf函数(2):
- 参数传递:传入格式字符串的地址,数组内容argv[1],argv[2]的地址。
- 函数调用:在for循环的时候调用。
- 源代码以及对应汇编代码:
图3-15 printf函数(2)
exit函数:
- 参数传递:传入参数,数字1。
- 函数调用:if(argc!=4)判断成立后调用。
- 源代码以及对应汇编代码:
图3-16 exit函数
atoi函数:
- 参数传递:传入参数argv[3]字符串的首地址。
- 函数调用:在循环中调用。
- 函数返回:将字符串转为整型变量后返回。
- 源代码以及对应汇编代码:
图3-17 atoi函数
sleep函数:
- 参数传递:传入argv[3]字符串转换为的整型变量。
- 函数调用:在循环中调用。
- 源代码以及对应汇编代码:
图3-18 sleep函数
getchar函数:
- 参数传递:无参数传入。
- 函数调用:在main函数当中调用。
- 源代码以及对应汇编代码:
图3-19 getchar函数
3.4 本章小结
本章介绍了编译的具体概念、过程以及能够达到的作用。通过hello.c代码以及编译后得到的hello.s分析了c语言如何转换成为汇编代码。解释分析了汇编代码如何实现变量、常量、赋值、关系操作以及函数操作等。
图3-20 第三章小结
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
将汇编语言的ascii码文件翻译成目标文件(可重定向目标文件),是一个二进制文件(不可执行,还没完成最后一步的链接),将hello.s翻译成hello.o文件。
汇编的作用:
汇编是将汇编语言转化为机器可直接识别执行的代码文件的过程,汇编器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中。.o文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
图4-1 汇编命令
gcc -c xxx.s -o xxx.o
将hello.s翻译成hello.o文件。
4.3 可重定位目标elf格式
4.3.1 readelf命令
图4-2 readelf命令
使用【命令】readelf -a hello.o > elf.txt 生成了elf.txt文件
4.3.2 ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件系统下的字的大小、字节序等内容。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。具体内容如下:
图4-3 ELF头
4.3.3 节头表
描述了.o文件中出现的各个节的类型、位置、所占空间大小等信息。
图4-4 节头表
4.3.4 重定位节
重定位节中包含了在代码中使用的一些外部变量等信息,在链接的时候需要根据重定位节的信息对这些变量符号进行修改。链接的时候链接器会根据重定位节的信息对外部变量符号决定选择何种方法计算正确的地址,通过偏移量等信息计算出正确的地址。
1)offset,偏移量。指立即数0x0位置的偏移量,即要修改的引用位置离main的偏移量
2)type,类型。重定位条目一共有32种type,如R_X86_64_PC32表明重定位的引用使用32位PC相对地址
3)symbol,符号。表明调用的符号,比如函数名
4)addend,加数。这个addend可能会带来疑惑,它代表引用位置与下一条指令的相对位置关系[3]。
图4-5 重定位节
如图所示,这里需要重定位的信息包括:.rodata中的模式串,puts,exit,printf,atoi,sleep,getchar这些符号和函数同样需要与相应的地址进行重定位。
4.3.5 符号表
.symtab存放在程序中定义和引用的函数和全局变量的信息。包括了hello.c中的各种函数:printf、exit、atoi等等。
图4-6 符号表
4.4 Hello.o的结果解析
使用objdump -d -r hello.o 生成hello.o的反汇编。
图4-7反汇编命令
我们分析o_asm反汇编文件,现在贴出具体代码
hello.o: 文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 20 sub $0x20,%rsp
c: 89 7d ec mov %edi,-0x14(%rbp)
f: 48 89 75 e0 mov %rsi,-0x20(%rbp)
13: 83 7d ec 04 cmpl $0x4,-0x14(%rbp)
17: 74 16 je 2f <main+0x2f>
19: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 20 <main+0x20>
1c: R_X86_64_PC32 .rodata-0x4
20: e8 00 00 00 00 callq 25 <main+0x25>
21: R_X86_64_PLT32 puts-0x4
25: bf 01 00 00 00 mov $0x1,%edi
2a: e8 00 00 00 00 callq 2f <main+0x2f>
2b: R_X86_64_PLT32 exit-0x4
2f: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
36: eb 48 jmp 80 <main+0x80>
38: 48 8b 45 e0 mov -0x20(%rbp),%rax
3c: 48 83 c0 10 add $0x10,%rax
40: 48 8b 10 mov (%rax),%rdx
43: 48 8b 45 e0 mov -0x20(%rbp),%rax
47: 48 83 c0 08 add $0x8,%rax
4b: 48 8b 00 mov (%rax),%rax
4e: 48 89 c6 mov %rax,%rsi
51: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 58 <main+0x58>
54: R_X86_64_PC32 .rodata+0x22
58: b8 00 00 00 00 mov $0x0,%eax
5d: e8 00 00 00 00 callq 62 <main+0x62>
5e: R_X86_64_PLT32 printf-0x4
62: 48 8b 45 e0 mov -0x20(%rbp),%rax
66: 48 83 c0 18 add $0x18,%rax
6a: 48 8b 00 mov (%rax),%rax
6d: 48 89 c7 mov %rax,%rdi
70: e8 00 00 00 00 callq 75 <main+0x75>
71: R_X86_64_PLT32 atoi-0x4
75: 89 c7 mov %eax,%edi
77: e8 00 00 00 00 callq 7c <main+0x7c>
78: R_X86_64_PLT32 sleep-0x4
7c: 83 45 fc 01 addl $0x1,-0x4(%rbp)
80: 83 7d fc 07 cmpl $0x7,-0x4(%rbp)
84: 7e b2 jle 38 <main+0x38>
86: e8 00 00 00 00 callq 8b <main+0x8b>
87: R_X86_64_PLT32 getchar-0x4
8b: b8 00 00 00 00 mov $0x0,%eax
90: c9 leaveq
91: c3 retq
通过和上一章中得到的hello.s汇编文本的对比,我可以分析出这些不同的地方:
- hello.o反汇编代码中值包括了.text代码段的汇编,且在每条指令前面有着对应的机器码16进制的表示。
- 在数的表示上,hello.s中的操作数为十进制,而hello.o反汇编代码中的操作数为十六进制。
- 在控制转移上,hello.s使用.L2和.LC1等段名称进行跳转,而反汇编代码使用目标代码的虚拟地址跳转。不过目前留下了重定位条目,跳转地址为零。它们将在链接之后被修改为正确的地址。
- 在函数调用上,hello.s直接call函数名称,而反汇编代码中call的是目标的虚拟地址。只有在链接之后才能计算出准确的运行执行的地址,目前目的地址是全0,并留下了重定位条目。如图。
图4-7 函数调用
关于机器语言和汇编语言:
hello.o实质上是机器可以识别的机器码二进制文件,而我们这里查看的是hello.o中.text段反汇编得到的汇编语言文本文件,汇编语言中一条指令对应着一条机器码指令,但是是用二进制表示的。两者之间是一一对应的关系。
汇编指令实质上机器指令便于记忆和阅读的书写格式——助记符,与人类语言接近,如add,mov等。例如:
图4-8 机器语言和汇编语言的对应
在这里,e8就对应着callq指令,而后面的00 00 00 00 对应的调用的地址,这里还没有链接过,所以是全0。
4.5 本章小结
本章介绍了汇编的概念和作用。经过汇编器,汇编语言转化为机器语言,hello.s文件转化为hello.o可重定位目标文件。我们分析了hello.o的elf格式文本,包括了elf头,节头表,以及接下来的节段信息,可重定向节,符号表等等。我们对比hello.s和hello.o,分析了机器语言和汇编语言的映射关系。比较出了汇编语言转换为机器语言出现的不一致的地方。
图4-9 第四章小结
第5章 链接
5.1 链接的概念与作用
链接的概念:
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(run time),也就是由应用程序来执行。链接是由叫链接器(linker)的程序自动执行的。
链接的作用:
链接使得分离编译(separate compilation)成为可能,我们不用将一个大型的应用程序组织成一个巨大的源文件,而是可以把它分解成为更小、更好管理的模块,可以独立的修改和编译这些模块。当我们改变这些模块中的一个时,只需要简单的重新编译它,并重新链接应用,而不必重新编译其他文件[4]。
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-1 ld链接命令
5.3 可执行目标文件hello的格式
使用readelf -a hello > out_elf.txt得到可执行目标文件的ELF格式文件。
5.3.1 ELF头
包含和可重定向目标文件ELF格式文件相类似的一些内容。描述了文件的总体格式,还包括了程序的入口点(entry point),也就是当程序运行到要执行第一条指令的地址。
图5-2 ELF头
5.3.2 节头表
描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。现在列出节头表,如下表所示。
节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 00000000004002e0 000002e0
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.gnu.propert NOTE 0000000000400300 00000300
0000000000000020 0000000000000000 A 0 0 8
[ 3] .note.ABI-tag NOTE 0000000000400320 00000320
0000000000000020 0000000000000000 A 0 0 4
[ 4] .hash HASH 0000000000400340 00000340
0000000000000038 0000000000000004 A 6 0 8
[ 5] .gnu.hash GNU_HASH 0000000000400378 00000378
000000000000001c 0000000000000000 A 6 0 8
[ 6] .dynsym DYNSYM 0000000000400398 00000398
00000000000000d8 0000000000000018 A 7 1 8
[ 7] .dynstr STRTAB 0000000000400470 00000470
000000000000005c 0000000000000000 A 0 0 1
[ 8] .gnu.version VERSYM 00000000004004cc 000004cc
0000000000000012 0000000000000002 A 6 0 2
[ 9] .gnu.version_r VERNEED 00000000004004e0 000004e0
0000000000000020 0000000000000000 A 7 1 8
[10] .rela.dyn RELA 0000000000400500 00000500
0000000000000030 0000000000000018 A 6 0 8
[11] .rela.plt RELA 0000000000400530 00000530
0000000000000090 0000000000000018 AI 6 21 8
[12] .init PROGBITS 0000000000401000 00001000
000000000000001b 0000000000000000 AX 0 0 4
[13] .plt PROGBITS 0000000000401020 00001020
0000000000000070 0000000000000010 AX 0 0 16
[14] .plt.sec PROGBITS 0000000000401090 00001090
0000000000000060 0000000000000010 AX 0 0 16
[15] .text PROGBITS 00000000004010f0 000010f0
0000000000000145 0000000000000000 AX 0 0 16
[16] .fini PROGBITS 0000000000401238 00001238
000000000000000d 0000000000000000 AX 0 0 4
[17] .rodata PROGBITS 0000000000402000 00002000
000000000000003b 0000000000000000 A 0 0 8
[18] .eh_frame PROGBITS 0000000000402040 00002040
00000000000000fc 0000000000000000 A 0 0 8
[19] .dynamic DYNAMIC 0000000000403e50 00002e50
00000000000001a0 0000000000000010 WA 7 0 8
[20] .got PROGBITS 0000000000403ff0 00002ff0
0000000000000010 0000000000000008 WA 0 0 8
[21] .got.plt PROGBITS 0000000000404000 00003000
0000000000000048 0000000000000008 WA 0 0 8
[22] .data PROGBITS 0000000000404048 00003048
0000000000000004 0000000000000000 WA 0 0 1
[23] .comment PROGBITS 0000000000000000 0000304c
000000000000002b 0000000000000001 MS 0 0 1
[24] .symtab SYMTAB 0000000000000000 00003078
00000000000004c8 0000000000000018 25 30 8
[25] .strtab STRTAB 0000000000000000 00003540
0000000000000158 0000000000000000 0 0 1
[26] .shstrtab STRTAB 0000000000000000 00003698
00000000000000e1 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
表5-3 节头表
5.3.3 程序头
程序头中,我们可以知道可执行目标文件的内容初始化的内存段,其中包括了内存段的权限类型,开始地址,总内存大小等等信息。
图5-4 程序头
5.3.4 段头部表
这个部分包含了将各连续的文件节映射到运行时内存段的信息。
图5-5 段头部表
5.3.5 其他部分
除此之外,在可执行目标文件ELF格式当中,我们还能看到一些信息。
有动态链接后才会生成的动态段(.dynamic section)
图5-6 动态段
还有一些重定向节,我这里猜测是因为动态链接只会链接部分目标文件的缘故,所以还会有一些符号还没有被重定向,在后面加载或者运行当中才会链接,所以这里还会有重定向节。
图5-7 重定向节
除此之外,还包括类似可重定向ELF文件的符号表等内容。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图5-8 edb中symbols viewer 查看各节起始位置
在edb的symbol窗口中,可以查看各节对应的名称、起始位置与结束的位置,与5.3中所展示出来的ELF格式文件中显示出来的相对应。
可以在edb的Data Dump窗口看到hello的虚拟地址空间分配的情况。代码段总是从0x400000处开始,后面是数据段。
对照程序头,我们可以看各内存段是什么。
图5-9 edb中Data Dump
显然这一段当中存着PHDR,同理其他地方保存着其他内存段。下面列出这些内存段各类型的作用。
PHDR保存程序头表。
INTERP指定在程序已经从可执行映射到内存之后,必须调用解释器。在这里解释器并不意味着二进制文件的内存必须由另一个程序解释。它指的是这样的一个程序:通过链接其他库,来满足未解决的引用。
通常/lib/ld-linux-so.2、/lib/ld-linux-ia-64.so.2等库,用于在虚拟地址空间中插入程序运行所需的动态库。对几乎所有的程序来说,可能c标准库都是必须映射的,还需要添加各种哭包括,GTK、数学库、libjpeg等。
LOAD表示一个从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串),程序的目标代码等等。
DYNAMIC段保存了其他动态链接器(即,INTERP中指定的解释器)使用的信息。
NOTE保存了专有信息。
虚拟地址空间的各个段,填充了来自ELF文件中特定段的数据[5]。
我们这里再查看.text即保存指令代码的节在虚拟内存中的存储。
图5-9 .text节
我们在hello的反汇编代码当可以看到,和指令开始的地址是对应的。
图5-10 指令起始地址
这里给出虚拟内存的大概映象,和上面我们在edb中查看的内存地址其实是大体对应的。
图5-11 内存映像
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
输入命令objdump -d -r hello > out_asm.txt,得到可执行文件的反汇编程序。 将hello.o与hello文件的反汇编代码进行对比分析,得到如下几方面的不同。
5.5.1 新增.plt和.init节
图5-12 .init节
.init节中定义了一个小函数,叫做_init,程序的初始化代码会调用它。
图5-13 .plt节符号解析代码
.plt节中包含了一部分符号解析代码,以及跳转到GOT表项的代码。
图5-14 .plt节跳转部分
.plt 的作用是为每一次模块外部的函数调用设置一小段跳转代码,对于每一项外部跳转,对应 .plt 中三条指令,和 .got 类似,plt 的前面部分也被系统"征用"了,这部分负责调用动态链接器中的符号解析函数完成动态解析工作,后续的部分才是对应具体外部跳转的指令。
在程序的编译阶段,.plt 跳转指令表项和 .got 表项就实现了绑定,其映射关系为:.plt 第二段跳转指令对应 .got 第四个表项,.plt 第二段跳转指令对应 .got 第五个表项,以此类推。
图5-14 PLT表和GOT表的映射跳转关系[6]
在 got 表中,并不是每一项都对应符号的引用,它的前三项被系统"征用"了,分别对应:
got[0]:当前 elf 文件中 .synamic 段的地址
got[1]:作为保留
got[2]: 动态链接器的符号解析函数
除了前三项,后面的项才被一一用作符号重定位。
关于puts、printf、exit等函数为什么在PLT表项当中,因为这些函数的定义的库是被动态链接的,PLT和GOT表实现一个中转站使得代码对外部函数的调用能够实现。大致的流程如下:
当程序中的指令引用到动态库中的函数时,执行顺序为:
- 指令跳转到 PLT 表
- PLT 表判断其对应的 GOT 表项是否已经被重定位
- 如果重定位完成,PLT 代码跳转到目标地址执行
- 如果未重定位,调用动态链接器为当前的引用进行重定位,重定位完成之后再跳转。
具体的流程,我们会在下面的重定位分析中说明。
5.5.2 实现各处地址的分配和重定位
指令分配虚拟地址:
图5-15 指令地址分配
在hello.o的反汇编程序中, main函数中的所有语句前面的地址都是从main函数开始从0开始依次递增的,而不是虚拟地址;经过链接后,每一条指令有了在内存中的虚拟地址,具体原因见5.4。
字符串常量的引用:
图5-16 字符串常量地址的重定位
在hello.o的反汇编程序中,字符串常量的位置是用0加%rip的值来表示的,这是由于当时字符串常量并未分配虚拟内存;而在hello的反汇编程序中,因为字符串常量在虚拟内存中有着具体的储存位置,所以在重定位过后,这里的访问地址修改为正确的地址。
函数的引用:
图5-17 函数引用地址重定位
在hello.o的反汇编程序中,函数引用地址还未重定位,所以是0;而在hello的反汇编程序中,重定位后,对于函数的引用有着准确的地址。
5.5.3 重定位具体过程分析
字符串常量地址的重定位:
我们结合hello.o的重定位条目,来看看如何对字符串常量地址的重定位的。字符串常量保存在.rodata节当中,实际上这里是对.rodata节进行重定位。objdump将重定位条目的信息注释在相应的指令下方,我们不用再在ELF文件当中查看。
图5-18 指令和重定位条目
我们能看到这里重定位条目的类型是R_X86_64_PC32就要求我们使用重定位PC相对引用的方法来进行重定位。具体算法如下:
图5-19 重定位算法
其中,refptr是指向引用的指针,refaddr是引用的运行时地址,ADDR(r.symbol)是符号运行时地址(这里指链接后字符串在内存中保存的地址),r.addend是引用和下一条指令的相对位置。
图5-20 链接前后指令
查看重定向条目我们可以获得:
- r.offset = 0x1c
- r.symbol = .rodata
- r.type = R_X86_64_PC32
- r.addend = -4
由图可知:
- main的运行时地址 ADDR(S) = 0x0401125
- 字符串保存地址 ADDR(r.symbol) = 0x402008
- 引用的运行时地址 refaddr = ADDR(S) + r.offset = 0x401141(图中111行c3的位置)
- callq下一指令运行时地址 = 0x401145 = refaddr - r.addend
根据图5-19的重定位算法,我们将修改0x0为0xec3,这正是图5-20引用处的最终值。现在让我们利用处理器进行相对寻址的过程来理解和验证上述重定位算法:当CPU执行引用字符串指令时,PC的值为0x401145,即引用指令的下一指令地址(在处理器中,111行指令进入执行阶段,下一指令进入取址阶段),为了执行111行指令,CPU将执行以下步骤:
1)将PC压入栈中
2)更新 PC <-- PC + 0xec3 =0x401145 + 0xec3 = 0x402008
显然0xec3是我们的重定位算法最后放在引用处的值,它对应于字符串储存地址减去111行下一指令(即112行)的运行时地址,即:ADDR(r.symbol) - (refaddr - r.addend)。可以看到,以上过程与重定位算法有完美的一致性。
函数引用的重定位:
我们结合hello.o的重定位条目,来看看如何对函数引用重定位的。这里以puts函数的引用为例。
图5-21 puts引用时的重定位条目
这里这个类型虽然是R_X86_64_PLT32,但也是采用PC相对重定位方法的(在有些机器上仍然显示的是R_X86_64_PC32),
图5-22 链接前后指令
查看重定向条目我们可以获得:
- r.offset = 0x21
- r.symbol = puts
- r.type = R_X86_64_PLT32
- r.addend = -4
由图可知:
- main的运行时地址 ADDR(S) = 0x0401125
- puts函数运行时地址 ADDR(r.symbol) = 0x401090
- 引用的运行时地址 refaddr = ADDR(S) + r.offset = 0x401146(图中112行46的位置)
- callq下一指令运行时地址 = 0x40114a = refaddr - r.addend
根据图5-19的重定位算法,我们将修改0x0为ff ff ff 46,这里ff ff ff 46是一个有符号数,表示的实际上是-0xba,那么正好下一条指令的地址0x40114a 加上这个偏移量 (-0xba) 就等于 0x401090,即为puts函数的运行时地址。
另外想要提到的是,puts函数是动态链接的,其实不是实际运行时地址,0x401090只是PLT表项中的一个中转地址。
图5-23 PLT中puts函数
可以看到这里并没有实际运行puts函数,而是接着跳转到GOT表当中去。这和动态链接的缘由有关,这里puts函数其实还需要再GOT表当中进行重定位,这个重定位的过程只有在第一次运行调用这个函数的时候才会实际执行。可以对照着上面对.plt节的解释当中,来看这段重定位流程,对照图5-14。
- 当首次调用外部函数的时候,此时产生了外部符号的访问需要,比如 puts 符号将会跳转到 PLT 对应的表项。
- 每个部分的 .plt 代码取出对应的 GOT 表项 0 中的数据,并跳转过去,由于初始化时 GOT 表项 0 中还未执行重定位,该表项中保存的是 .PLT section 的首地址,因此程序跳转到了 .PLT section 首地址处。
- .PLT section 首地址处的代码将会跳转到 GOT 表的第三项,也就是执行 ld 链接程序,解析 puts 符号,为其重定位,重定位完成之后,重新填充 GOT 表项 0,此时 GOT 表项 0 中保存的就是 puts 的真实地址
- 程序第二次调用 puts 时,依旧跳转到 .PLT 中对应的代码中,该代码取出对应 GOT 表项 0 中的值并跳转,因为此时 GOT 表项 0 中保存的是正确的 puts 地址,因此也就直接执行了 puts 函数。
- 这就是动态链接时延迟绑定的实现,从上面延迟绑定的过程可以看出,所有外部符号的引用在第一次访问时都会执行一次符号的重定位,这个重定位会导致 GOT 表项的重新填充,而重新填充后的 GOT 表项保存的就是对应的外部符号地址,下一次访问就不需要再执行重定位工作了。
- 在程序中没有使用到的外部符号(但是共享库中存在)则不会发生重定位,从而实现延迟绑定[6]。
5.6 hello的执行流程
用edb的analyze功能(用终端运行edb,在edb中右键→analyze here,分别在ld-xxx.so和hello区域analyze一次,在终端会出现如下信息)可以发现动态链接库中依次出现了如下函数:
图5-24 hello中的函数及地址
5.7 Hello的动态链接分析
hello中的部分函数采用动态链接的方式进行链接,且使用了PLT和GOT表来实现延迟绑定,即在调用前GOT表项中的跳转地址还没有重定位,此时指向PLT表首地址,而首地址的代码将会调用连接器对引用的符号进行重定位。GOT相应的表项将会修改为正确的函数外部地址。在第二次调用的时候,就能够直接正确跳转了。
我们可以在可执行目标文件ELF格式当中找到关于PLT表和GOT表的信息:
图5-25 PLT和GOT表位置
图5-26 dl_init前GOT表区
图5-27 dl_init后GOT表区
可以看出在dl_init后,已经初次解析了程序中的外部符号,使得重定向了这些地址,此时GOT表区中的地址发生了改变,指向了实际运行的地址。具体流程可参考5.5节链接过程分析。
5.8 本章小结
本章主要介绍了本章主要介绍了链接的的概念以及链接的作用,主要是将将预编译好了的一个目标文件(hello.o)或若干目标文件外加链接库合并成为一个可执行目标文件(hello)。
将hello.o文件经过链接器(ld)生成了可执行目标文件hello。
分析了hello的ELF格式,用edb查看了hello的虚拟地址空间,对可执行目标文件hello进行反汇编,得到了反汇编程序,并与hello.o的反汇编程序进行比较。
深入学习了链接的过程,包括符号解析以及重定位。了解了动态链接的过程,了解了PLT表和GOT表工作的原理。
图5-28 第五章小结
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
进程的作用:
在现代系统上运行一个程序时,我们会得到一个假象,好像我们的程序是系统中唯一运行的程序一样。我们的程序好像独占处理器和内存。处理器好像无间断地一条接一条执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象是通过进程的概念提供的。
进程提供给应用程序的关键抽象:1)一个独立的逻辑控制流,提供一个程序独占处理器的假象;2)一个私有的地址空间,提供一个程序独占地使用内存系统的假象。
6.2 简述壳Shell-bash的作用与处理流程
作用:
shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行,求值步骤解析命令行,并根据解析结果运行程序。完成后重复上述步骤,直到用户退出shell。从而完成用户与计算机的交互来操作计算机。
处理流程:
- Shell打印一个命令行提示符,等待用户输入指令。
- 在用户输入指令后,从终端读取该命令并进行解析,若该命令为shell的内置命令,则立即执行该命令。
- 若不是内置命令,是一个可执行目标文件,则调用fork()函数创建子进程,其地址空间与shell父进程完全相同,包括只读代码段、读写数据段、堆及用户栈等。调用execve()函数在当前进程(新创建的子进程)的上下文中加载并运行hello程序。将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间。调用hello程序的main()函数,hello程序开始在一个进程的上下文中运行。
- 若将该文件转到后台运行,则shell返回到循环的顶部,等待下一个命令行。
- 完成上述过程后,shell重复上述过程,直到用户退出shell。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。调用fork函数后,新创建的子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本,包括代码、数据段、堆、共享库以及用户栈,子进程获得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。fork被调用一次,却返回两次,子进程返回0,父进程返回子进程的PID。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
在shell中给hello folk一个进程输入下面这段命令:
图6-1 folk进程
这时已经folk了一个子进程,供hello来运行。
6.4 Hello的execve过程
exceve函数在当前进程的上下文中加载并运行一个新程序。exceve函数加载并运行可执行目标文件,并带参数列表和环境变量列表(参数argv[],环境变量envp)。只有当出现错误时,exceve才会返回到调用程序。所以,与fork一次调用返回两次不同,在exceve调用一次并从不返回。
当加载可执行目标文件后,execve调用启动代码,启动代码设置栈,将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序,由此将控制传递给新程序的主函数。下图为:execve hello可执行文件的结果。
图6-2 execve结果(带参数)
6.5 Hello的进程执行
逻辑控制流和时间片:
使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一的对应于包含在运行时动态链接到程序的共享对象中的指令。这个PC的序列叫做逻辑控制流,或者简称逻辑流。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。其中,一个进程执行它的控制流的一部分的每一时间段叫做时间片。然后在用户态下执行并保存上下文。
用户模式和内核模式:
用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据。内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
用户态与核心态转换:
为如果在此期间内发生了异常或系统中断,则内核会休眠该进程,并在核心态中进行上下文切换,控制将交付给其他进程。例如在hello中的例子:
当hello 执行到 sleep时,hello 进程会休眠,内核进行上下文切换,控制交付给其他进程,一段时间后再次上下文切换,恢复hello在休眠前的上下文信息,控制权回到 hello 继续执行。
当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。有类似的过程如下:
图6-3 上下文切换剖析
6.6 hello的异常与信号处理
运行过程中可能出现的异常种类由四种:中断、陷阱、故障、终止。
- 中断:来自I/O设备的信号,异步发生。硬件中断的异常处理程序被称为中断处理程序。
- 陷阱:是执行一条指令的结果。调用后返回到下一条指令。
- 故障:由错误情况引起,可能能被修正。修正成功则返回到引起故障的指令,否则终止程序。
- 终止:不可恢复,通常是硬件错误,这个程序会被终止。
6.6.1 正常运行
以参数 7203610826 季世宇 1 运行可执行程序,正常运行时 hello每sleep 1秒后打印一行“Hello 7203610826季世宇”,共打印八次。打印完毕后,调用getchar()函数,等待用户输入回车后程序终止。
图6-4 正常运行的结果
6.6.2 不停乱按
当在程序执行过程中乱按时,按下的字符串会直接显示,但不会干扰程序的运行。如果在中途摁下了回车符,会被一起缓存在stdin缓冲区内,所以在程序调用getchar函数后,会读取stdin中的回车,所以程序最后不摁回车便能正常终止。并且在中途摁下回车符后的按下的符号,也会在缓冲在stdin中,getchar在读取stdin中空格后正常终止。所以之后的符号会被当成shell的输入。如图:
图6-5 输入到缓冲区示意图
图6-6 乱按(包括回车)
6.6.3 按ctrl-z,及各种命令
在程序运行中途按下ctrl-z,进程收到 SIGSTP 信号,hello 进程挂起。
图6-7 按下ctrl-z
输入ps,可以查看到被挂起hello及其pid。
图6-8 输入ps
输入jobs,可以打印出被挂起的hello的jid等信息。
图6-9 输入jobs
输入pstree -p,可以打印出进程树,我们可以看到hello的上下关系。
图6-10 输入pstree -p
输入fg,此时将继续执行被挂在后台的hello程序,而后输入回车正常终止。
图6-11 输入fg
输入kill -9 [pid],将会终止挂在后台的hello程序。
图6-12 输入kill
6.6.4 按ctrl-c
进程收到 SIGINT 信号,终止 hello。然后输入ps中查询不到其PID,在job中也没有显示,hello已经被彻底终止。
图6-13 按下ctrl-c
6.7本章小结
本章主要介绍了进程的概念与作用。介绍了壳Shell-bash作用与处理流程。明确了hello的fork进程创建过程与execve过程,通过调用fork()函数与execve()来实现。介绍了hello是如何在shell中作为一个子进程执行的。
分析了hello执行过程中按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等出现的异常、产生的信号,使用各种命令查看进程的状态。
图6-14 第六章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址(Logical Address):
逻辑地址为由程序产生的与段有关的偏移地址。逻辑地址分为两个部分,一个部分为段基址,另一个部分为段偏移量。在hell.o的反汇编代码中的地址即为逻辑地址,需要加上相应的段基址才能得到真正的地址。
线性地址(Linear Address):
线性地址是逻辑地址到物理地址变换之间的中间层。在各段中,逻辑地址是段中的偏移地址,其偏移量加上基地址就是线性地址。可执行目标文件hello反汇编代码中的偏移地址(逻辑地址)与基地址相加后,即得到了对应内容的线性地址。
虚拟地址(Virtual Address):
虚拟地址是指程序访问存储器所使用的逻辑地址。使用虚拟寻址时,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送至内存前先转换成适当的物理地址。概念与线性地址类似。
物理地址(Physical Address):
计算机系统的主存被组织成一个由 M 个连续的字节大小的单元组成的数组,每一个字节都有一个唯一的物理地址。第一个字节的地址为0,第二个为1,再下一个为2,依此类推。在hello的运行过程中,hello内的虚拟地址经过地址翻译后得到的即为物理地址,CPU运行中通过物理地址来访问内存。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段选择符和段内偏移量组成,线性地址为段首地址与逻辑地址中的偏移量组成。其中,段首地址存放在段描述符中。而段描述符存放在描述符表中,也就是GDT(全局描述符表)或LDT(局部描述符表)中。
段选择符的构成如下图所示:由一个16位长的字段组成;其中前13位是索引号,用来确定当前使用的段描述符在描述符表中的位置;后面3位表示一些硬件细节,包含TI与RPL:TI选择全局(GDT)或局部描述符表(LDT),RPL选择内核态与用户态。
图7-1 段选择符
根据TI的不同,判断应该选择全局描述符表还是局部描述符表,然后根据寄存器得到地址和大小;查看段选择符的索引,在段描述符表中找到对应的段描述符;就可以得到开始的基地址,加上偏移量就能得到相应的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(虚拟地址)由虚拟页号VPN和虚拟页偏移VPO组成。页表是一个页表条目 (Page Table Entry, PTE)的数组。
虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。我们假设PTE是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还没有被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。
当引用内容时,首先内存管理单元从线性地址中抽取出虚拟页号,并且检查高速地址变址缓存,看它是否缓存于DRAM中,若命中,则再查找对应组中是否命中。若命中,将缓存的内容返回给内存管理单元。若不命中,即需要的内容不在物理内存中,则产生缺页中断,需要从虚拟内存所给出对应的磁盘的内容重新加载到物理内存中。
图7-2 页表管理
7.4 TLB与四级页表支持下的VA到PA的变换
TLB,翻译后备缓冲器。TLB是一个小的,虚拟寻址的缓存,其中每一行都保存着一个由单个PLE组成的块。TLB通常有高度的相联度。如图。
图7-3 虚拟地址中用以访问TLB的组成部分
若TLB命中,会经历如下步骤:CPU产生一个虚拟地址;MMU从TLB中取出相应的PTE;MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存;高速缓存/主存将所请求的数据字返回给CPU。
若TLB不命中,对于四级页表来说虚拟地址被划分成4个VPN和1个VPO,VPN的每个片表示一个到第i级页表的索引,即偏移量,CR3寄存器包含L1页表的物理地址余下的页表中,第j级页表中的每个PTE,1≤j≤3,都指向j+1级的某个页表的基址。最后在L4页表中对应的PTE中取出PPN,与VPO连接,从而形成物理地址PA。
图7-4 使用k级页表的地址翻译
7.5 三级Cache支持下的物理内存访问
MMU将物理地址发给L1缓存,缓存从物理地址中取出缓存偏移CO、缓存组索引CI以及缓存标记CT。若缓存中CI所指示的组有标记与CT匹配的条目且有效位为1,则检测到一个命中条目,读出在偏移量CO处的数据字节,并把它返回给MMU,随后MMU将它传递给CPU。若不命中,则去L2、L3、主存中寻找需要的内容,命中时将数据传给CPU同时更新各级cache的储存。如图为Core i7地址翻译的概况。其中就包括三级缓存支持下的地址翻译。
图7-5 Core i7地址翻译的概况
7.6 hello进程fork时的内存映射
Linux通过将一个虚拟内存区域与磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射。虚拟内存区域可以映射到两种类型的对象中的一种:1)Linux文件系统中的普通文件2)匿名文件。
当fork函数被当前进程调用时,内核创建一个子进程,为子进程创建各种数据结构,并分配给它一个的PID,这个PID和父进程不相同,同时创建虚拟内存,创建子进程的mm_struct、区域结构和页表的原样副本(上下文)。将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork从子进程中返回时,子进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行可执行目标文件hello,即用hello程序替代当前程序。加载并运行hello需要如下几个步骤:
- 删除已存在的用户区域,即:删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域,即:为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有、写时复制的。代码和数据区域被映射为hello文件中的.text和.data 区。bss 区域是二进制零的,映射到匿名文件,其大小包含在hello 中,但没有内容。栈和堆区域也是二进制零的,初始长度为零。下图概括了私有区域的不同映射。
- 映射共享区域,即:如果hello程序与共享对象(或目标)链接,其中包括了标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC) ,即:设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
图7-6 加载器是如何映射用户地址空间的区域的
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。如图所示:CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位为0推断出VP3未被缓存,并触发一个缺页异常。缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,即存放在PP3中的VP4。若VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中的事实。
图7-7 VM缺页之前。对VP3中的字的引用会不命中,从而触发了缺页
接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。下图展示了缺页之后的状态。
图7-8 VM缺页之后。缺页处理程序选择VP4作为牺牲页,并从磁盘上的VP3副本取代它
7.9 动态存储分配管理
动态储存分配管理使用动态内存分配器(如malloc)来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。分配器将堆视为一组不同大小的块的集合。每个块就是一个连续的虚拟内存页,要么是已分配的,要么是空闲的。
已分配的块显式地保留为供应用程序使用。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格:
- 显示分配器:要求应用显示地释放任何已分配的块。
- 隐式分配器:要求分配器检测一个已分配的块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器。
在hello中,printf会调用malloc,malloc返回一个指针,指向大小至少为size字节的内存块。当程序不再需要malloc分配的区域时,需要通过free函数进行释放。如图。
图7-9 malloc函数和free函数
带边界标签的隐式空闲链表分配器管理:
带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、(可能的)额外填充以及一个字的尾部组成。
隐式空闲链表:空闲块通过头部的大小字段隐含地连接着。分配器遍历堆中所有的块,间接地遍历整个空闲块的集合。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配和最佳适配。分配器在面对释放一个已分配块时,可以合并相邻的空闲块,其中一种简单的方式,是利用隐式空闲链表的边界标记来进行合并。
显式空间链表管理:
显式空闲链表是将堆的空闲块组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。进行内存管理。在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10 本章小结
本章主要介绍了hello的存储器地址空间。分析了段式管理是如何完成逻辑地址到线性地址(虚拟地址)的变换的;页式管理是如何完成线性地址到物理地址的变换的。分析了TLB与四级页表支持下的VA到PA的变换、三级Cache支持下的物理内存访问的流程。
介绍了hello进程fork与execve时的内存映射。介绍了缺页故障与缺页中断的处理。分析了动态存储分配管理。
图7-10 第七章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件。所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入输出都被当做对相应文件的读和写来执行。
设备管理:unix io接口。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口
打开文件:
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个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 时执行读操作会触发EOF (end of file) 条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号”。
类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件:
当一个应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 Unix IO接口函数
打开文件:
int open(char *filename, int flags, mode_t mode);
open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程当中没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件,同时也可以是一个或者更多为掩码的或,为写提供给一些额外的指示。mode参数指定了新文件的访问权限位。
返回:若成功则为新文件描述符,若出错为-1。
关闭文件:
int close(int fd);
调用close函数,通知内核结束访问一个文件,关闭打开的一个文件。
返回:若成功则为0, 若出错则为-1。
读文件:
ssize_t read(int fd, void *buf, size_t n);
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。
返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。。
写文件:
ssize_t write(int fd, const void *buf, size_t n);
调用从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
返回: -1表示出错,否则,返回内存向文件fd输出的字节的数量。
8.3 printf的实现分析
(以下格式自行编排,编辑时删除)[转]printf 函数实现的深入剖析 - Pianistx - 博客园
以下是printf函数的函数体,首先观察printf的实现代码:
1. int printf(const char *fmt, ...)
2. {
3. int i;
4. char buf[256];
5.
6. va_list arg = (va_list)((char*)(&fmt) + 4);
7. i = vsprintf(buf, fmt, arg);
8. write(buf, i);
9.
10. return i;
11. }
可以发现printf的输入参数是fmt,但是后面是不定长的参数,同时在printf内存调用了两个函数,一个是vsprintf,一个是write。
我们来看vsprintf(buf, fmt, arg) 函数,其返回的是打印内容的长度
1. int vsprintf(char *buf, const char *fmt, va_list args)
2. {
3. char* p;
4. char tmp[256];
5. va_list p_next_arg = args;
6.
7. for (p=buf;*fmt;fmt++)
8. {
9. if (*fmt != '%')
10. {
11. *p++ = *fmt;
12. continue;
13. }
14.
15. fmt++;
16.
17. switch (*fmt)
18. {
19. case 'x':
20. itoa(tmp, *((int*)p_next_arg));
21. strcpy(p, tmp);
22. p_next_arg += 4;
23. p += strlen(tmp);
24. break;
25. case 's':
26. break;
27. default:
28. break;
29. }
30. }
31.
32. return (p - buf);
33. }
现在来看write系统函数:
1. write:
2. mov eax, _NR_write
3. mov ebx, [esp + 4]
4. mov ecx, [esp + 8]
5. int INT_VECTOR_SYS_CALL
int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数
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.
15. cli
16.
17. ret
这个函数里,sys_call实现了显示格式化了的字符串,也就是ASCII到字模库到显示vram的信息。
然后显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。从而实现了字符串的显示。
8.4 getchar的实现分析
当程序调用getchar(),程序等待用户输入。用户输入的每个字符实际上是一个中断,其触发事件为键盘按下。用户输入的字符被存放在键盘缓冲区中。当按下的字符为回车时(回车字符也放在缓冲区中),中断处理程序将结束getchar并返回stdin中缓存的第一个字符。
返回:若文件结尾则返回-1(EOF),否则返回一个字符的ascii码。
8.5 本章小结
本章主要介绍了hello的IO管理,分析了Unix I/O接口及其相关函数;分析了printf()函数和getchar()函数的具体实现。
图8-1 第八章小结
结论
hello程序的一生可以总结如下:
- 最开始是用C语言编写出hello.c程序的代码,保存为.c文件。
- 为了运行hello.c程序,hello.c首先经过预处理器(cpp)生成文件hello.i。
- 然后,编译器(cc1)将hello.i翻译为汇编程序hello.s。此时,高级语言被翻译为汇编语言,这个时候编译器会对代码做出一定的优化。
- 汇编器(as)将hello.s汇编指令文件翻译成机器语言指令,得到一个二进制文件hello.o。可以查看它的可重定位目标文件ELF格式以及.text节的反汇编指令。hello.o是由不同大小和功能的节组成的。
- 经过链接器(ld),将hello.o可重定位目标文件和动态链接库链接成可执行目标程序hello。其中对于外部函数的定义库采用动态链接,延迟绑定的方法。只有在第一次解析外部符号的时候,才会真正重定位。
- 运行hello时,shell利用fork()函数创建子进程,为子进程分配一个与父进程相同但独立的虚拟内存空间,再用execve()运行hello程序,加载到该子进程当中,映射虚拟内存。
- CPU为hello进程分配时间片,进行取指、译码、执行等流水线操作。hello顺序执行自己的逻辑控制流。
- MMU和CPU在执行过程中通过三级缓存和多级页表,从逻辑地址,到虚拟地址,最终得到物理地址,从物理内存中取得数据。
- hello程序的输出与Unix IO接口相关。
- 在程序运行结束后,向父进程发送信号,父进程将其回收,内核把它从系统中清除。
这就是hello所经历的一生。
附件
hello.c | C语言源代码文件 |
hello.i | hello.c经过预处理产生的文件 |
hello.s | hello.i经过编译产生的文件 |
hello.o | hello.s经过汇编产生的文件 |
hello | hell.o经过链接产生的可执行文件 |
o_elf.txt | hello.o的elf格式文件,生成的txt |
o_asm.txt | hello.o的反汇编,生成的txt |
out_elf.txt | hello的elf格式文件,生成的txt |
out_asm.txt | hello的反汇编,生成的txt |
参考文献
[1] 百度百科——预处理. https://baike.baidu.com/item/%E9%A2%84%E5%A4%84%E7%90%86/7833652?fr=aladdin
[2] 百度百科——编译. https://baike.baidu.com/item/%E7%BC%96%E8%AF%91/1258343?fr=aladdin#4
[3] 知乎专栏——详解:链接中的重定位. https://zhuanlan.zhihu.com/p/419683114
[4] Randal E.Bryant等.深入理解计算机系统(原书第3版)[M]. 北京:机械工业出版社,2016.7:2.
[5] ELF(三)程序头表. https://blog.csdn.net/ylcangel/article/details/18145155
[6] 程序的动态链接1 —— plt和got. 程序的动态链接1 - plt 和 got - 知乎
[7] [转]printf 函数实现的深入剖析. https://www.cnblogs.com/pianist/p/3315801.html