计算机系统(初发布于2022/5/15,修改于2022/5/20)
大作业
题 目 程序人生-Hello’s P2P
专 业
学 号
班 级
学 生 沈业力
指 导 教 师 史先俊
计算机科学与技术学院
2022年5月
摘 要
本论文目的在于针对hello程序运行的一生来映射所有程序是如何生成执行最后结束。主要内容包括hello是如何一步步通过预处理、编译、汇编、链接,以及hello在运行过程中是如何接受操作系统管理以及如何使用硬件完成数据交换。本论文主要使用的方法是参考文献法、实证研究法、个案研究法、经验法。本论文成果在于探清了hello——作为一个普遍进程——是如何运行的,实际意义在于对计算机系统以及组成有了更加深入的理解,为今后更加深入的研究打好基础。
关键词:操作系统;编译;内存管理;进程;预处理;汇编;链接
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述................................................... - 4 -
1.1 Hello简介............................................ - 4 -
1.2 环境与工具........................................... - 4 -
1.3 中间结果............................................... - 4 -
1.4 本章小结............................................... - 4 -
第2章 预处理............................................... - 5 -
2.1 预处理的概念与作用........................... - 5 -
2.2在Ubuntu下预处理的命令................ - 5 -
2.3 Hello的预处理结果解析.................... - 5 -
2.4 本章小结............................................... - 5 -
第3章 编译................................................... - 6 -
3.1 编译的概念与作用............................... - 6 -
3.2 在Ubuntu下编译的命令.................... - 6 -
3.3 Hello的编译结果解析........................ - 6 -
3.4 本章小结............................................... - 6 -
第4章 汇编................................................... - 7 -
4.1 汇编的概念与作用............................... - 7 -
4.2 在Ubuntu下汇编的命令.................... - 7 -
4.3 可重定位目标elf格式........................ - 7 -
4.4 Hello.o的结果解析............................. - 7 -
4.5 本章小结............................................... - 7 -
第5章 链接................................................... - 8 -
5.1 链接的概念与作用............................... - 8 -
5.2 在Ubuntu下链接的命令.................... - 8 -
5.3 可执行目标文件hello的格式........... - 8 -
5.4 hello的虚拟地址空间......................... - 8 -
5.5 链接的重定位过程分析....................... - 8 -
5.6 hello的执行流程................................. - 8 -
5.7 Hello的动态链接分析........................ - 8 -
5.8 本章小结............................................... - 9 -
第6章 hello进程管理.......................... - 10 -
6.1 进程的概念与作用............................. - 10 -
6.2 简述壳Shell-bash的作用与处理流程.. - 10 -
6.3 Hello的fork进程创建过程............ - 10 -
6.4 Hello的execve过程........................ - 10 -
6.5 Hello的进程执行.............................. - 10 -
6.6 hello的异常与信号处理................... - 10 -
6.7本章小结.............................................. - 10 -
第7章 hello的存储管理...................... - 11 -
7.1 hello的存储器地址空间................... - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................................ - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理........................................................ - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................................ - 11 -
7.5 三级Cache支持下的物理内存访问 - 11 -
7.6 hello进程fork时的内存映射......... - 11 -
7.7 hello进程execve时的内存映射..... - 11 -
7.8 缺页故障与缺页中断处理................. - 11 -
7.9动态存储分配管理.............................. - 11 -
7.10本章小结............................................ - 12 -
第8章 hello的IO管理....................... - 13 -
8.1 Linux的IO设备管理方法................. - 13 -
8.2 简述Unix IO接口及其函数.............. - 13 -
8.3 printf的实现分析.............................. - 13 -
8.4 getchar的实现分析.......................... - 13 -
8.5本章小结.............................................. - 13 -
结论............................................................... - 14 -
附件............................................................... - 15 -
参考文献....................................................... - 16 -
P2P指的是Program To(two) Process,是指从程序向进程的转变过程。正如CSAPP课本所示,GCC编译器驱动程序读取源文件hello.c,并依次执行如下步骤:经过cpp(预处理器)预处理(对#开头语句修改,插入头文件,预处理宏定义)进行修改生成hello.i;hello.i传入ccl(编译器)生成汇编程序hello.s;hello.s经过as(汇编器)生成可重定位目标程序hello.o;hello.o最终通过ld(链接器)完成静/动态链接后,将相关库函数(printf等)写入ELF文件,生成可执行文件hello。在shell中输入./hello后调用fork以及execve函数加载运行。
020指的是zero to zero,如果说P2P模式对应软件构造课程中build阶段,那么020则对应run阶段。第一个0指的是在./hello之前,内存中本没有hello的数据信息,在shell的fork以及execve相互作用下加载器把hello的代码以及数据载入内存,mmap则分配虚拟内存,在cache、流水线等硬件配合下执行代码直至结束。第二个0指的是在程序结束后,hello进程会被回收,内核删除内存中的相关代码和数据,“抹杀”了hello曾经来过的痕迹,实现归零(to zero)。
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件:Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz 2.30 GHz
1 TB SSD
16 GB RAM
软件:Windows 10,VMware 15.5.7,Ubuntu 20.04
调试工具:Visual Studio 2022 community 64-bit;
gedit,gcc,notepad++,readelf, objdump, hexedit, edb
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 |
作用 |
hello.i |
hello.c预处理后得到的文件 |
hello.s |
编译后的汇编语言文件 |
hello.o |
汇编后得到的可重定位目标文件 |
hello.elf |
用readelf读取hello.o得到的ELF格式信息 |
Dhello.s |
反汇编hello.o得到的反汇编文件 |
helloout.elf |
由hello可执行文件生成的.elf文件 |
Dhelloout.s |
反汇编hello可执行文件得到的反汇编文件 |
hello |
最终链接得到的可执行文件 |
图表 1 中间产物
本文首先根据自白介绍了P2P,020模式,列出了大作业所用到的硬件软件环境,以及相关调试工具,最后列出来过程中的中间文件
(第1章0.5分)
概念:
程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,预处理阶段是指预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,得到的结果再由编译器核心进一步编译。
作用:
1、将#include后面的头文件内容直接插入程序文本;
2、将#define定义的宏用实际值替换;
3、预编译程序将根据有关的文件,将那些不必要的代码(如条件编译指令,如#ifdef,#ifndef,#else,#elif,#endif等)过滤掉;
4、识别与替换特殊符号。例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
预处理所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。这个文件的含义同没有经过预处理的源文件是相同的,但内容有所不同。简单来说,预处理是一个文本插入与替换的过程,生成的hello.i文件仍然是文本文件[2]。
命令:
cpp hello.c > hello.i
截图:
图表 2 预处理截图
图表 3 hello.i
可以看到,hello.i的行数相比hello.c大大增加,达到了3060行。而且main函数后面的内容没有改变,只是头文件被插入,前面增加的行是stdio.h,unistd.h,stdlib.h的依次展开。
以stdio.h为例。由于stdio.h是用<>括起来的,所以在系统指定目录下寻找(usr/include),而双引号则是在当前文件夹查找,找不到再系统指定目录下查找。预处理包括:1.将所有的#define删除,并展开所有的宏定义;2.处理所有的预编译指令,例如:#if,#elif,#else,#endif;3.处理#include预编译指令,将被包含的文件插入到预编译指令的位置;4.添加行号信息文件名信息,便于调试;5.删除所有的注释:// /**/;6.保留所有的#pragma编译指令,因为在编写程序的时候,我们经常要用到#pragma指令来设定编译器的状态或者是指示编译器完成一些特定的动作。7.生成.i文件。
原始程序没有#define,并不代表stdio.h没有。在/uer/include目录下找到stdio.h后,若stdio.h文件中使用了#define语句,则按照上述流程继续递归地展开,直到所有#define语句都被解释替换掉为止。有其他成分,比如注释等,则按上一段讲的预处理流程递归执行。[2]
本章主要介绍了预处理的概念及作用、并结合Ubuntu系统下hello.c文件实际预处理之后得到的hello.i程序对预处理结果进行了解析。
概念:
编译过程就是利用编译器(ccl)把预处理完的.i文件进行一系列词法分析、语法分析、语义分析以及优化后生成相应的.s汇编代码文件。本作业中指的就是hello.i生成hello.s的过程。
作用:
将代码转成汇编代码,主要作用有:
1、扫描(词法分析)。将源代码程序输入扫描器,将源代码的字符序列分割成一系列记号。例array[index] = (index + 4) * (2 + 6);
2、语法分析。基于词法分析得到的一系列记号,生成语法树。
3、语义分析。由语义分析器完成,指示判断是否合法,并不判断对错。又分静态语义:隐含浮点型到整形的转换,会报warning;动态语义:在运行时才能确定:例1除以3 。
4、源代码优化(中间语言生成)。中间代码(语言)使得编译器分为前端和后端,前端产生与机器(或环境)无关的中间代码,编译器的后端将中间代码转换为目标机器代码,目的:一个前端对多个后端,适应不同平台。
5、代码生成,目标代码优化。编译器后端主要包括:代码生成器:依赖于目标机器,依赖目标机器的不同字长,寄存器,数据类型等。目标代码优化器:选择合适的寻址方式,左移右移代替乘除,删除多余指令[2]。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
命令:gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s
截图:
图表 4 编译结果
3.3.1 数据类型
3.3.1.1 常量
常量在汇编代码中以立即数的形式存在。以if (argc!=4)为例,4是一个常量,在hello.s中对应的汇编语句为:
cmpl $4, -20(%rbp)
在这里4前面有$,以立即数形式存在,保存在.text中,作为指令的一部分。同样的道理,下面的for循环:
for(i=0;i<8;i++){
printf("Hello %s %s\n",argv[1],argv[2]);
sleep(atoi(argv[3]));
}
和exit(1)中的8,1,2,3,0也是作为汇编代码的一部分存在于.text中,对应汇编代码为:
图表 5 for循环
除了数字常量,还有字符串常量,printf()、scanf()中的字符串就被存储在.rodata节中作为本地常量存在。我们可以看见,在hello.s文件开头有这么一段代码:
.LC0:
.string "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201"
.LC0代表local constant 0,第0本地常量,.string代表字符串,\XXX为UTF-8编码,一个汉字对应三个字节。
3.3.1.2 全局变量 and 静态变量
全局变量都在头文件,hello.s并没有全局变量。也没有静态变量。
3.3.1.3 局部变量
唯一的局部变量i(4字节int型)在运行时保存在栈中。Hello.c第11行:int i
赋值操作,hello.c第17行:for(i=0;i<8;i++)
对应hello.s中的代码:
初值:movl $0, -4(%rbp)
比较:cmpl $7, -4(%rbp)
加一:addl $1, -4(%rbp)
因此,i储存在栈中位置为R[%rbp]-4的地方。
3.3.2.1 赋值
hello.s中只有对应于hello.c中局部变量i的赋值操作:
hello.c的赋值语句:for(i=0;i<8;i++)
hello.s的赋值语句:movl $0, -4(%rbp)。
此外,局部变量赋值用mov指令完成,具体的指令选择要根据变量数据类型大小来决定。用b,w,l,q分别代表1,2,4,8字节。
3.3.2.2 算术操作
加法——自加操作符++,在for循环中每一次循环结束i使用自加操作符++进行加一操作,对应汇编语言为:
addl $1, -4(%rbp)
初次之外,还有寄存器值得加减操作,整个hello.s的算数操作如下:
1、addl $1, -4(%rbp) i++
2、subq $32, %rsp 分配栈帧
3、addq $8, %rax
addq $24, %rax 改变数组偏移
3.3.2.3 关系操作与控制转移
由于两者密不可分,因此放在一起讲。
hello.s中涉及的关系操作与控制转移如下:
1、cmpl $4, -20(%rbp)
je .L2
这两行汇编代码对应hello.c中:
if(argc!=4){
printf("用法: Hello 学号 姓名 秒数!\n");
exit(1);
}
检查argc是否不等于4,设置条件码,一旦不相等就跳转到指定地址。
2、cmpl $7, -4(%rbp)
jle .L4
这两行对应于循环体中:
for (i = 0; i < 8; i++)
每次循环结束后判定是否退出循环。
3.3.2.4 数组/指针/结构操作
数组:
主函数main的参数中有指针数组char *argv[]
int main(int argc, char *argv[]) {…}
在argv数组中,argv[0]指向输入程序的路径和名称,argv[1],argv[2],argv[3]分别表示三个字符串,在我们的要求中分别对应学号,姓名,休眠秒数。
由hello.s第23行:
movq %rsi, -32(%rbp)
以及34-40行:
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
还有44-47行:
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rax
movq %rax, %rdi
可以推断出esi寄存着argc的首地址(和rsi的作用也对应上了),并一起复制到地址R[rbp]-32中。然后char*的大小为8字节,因此,按照基址-变址寻址法(如movq -32(%rbp), %rax)访问argv[1]、argv[2]和argv[3],变址为8的倍数。
实际上上述汇编代码也可以视为指针操作。
3.3.2.5 函数操作
x86-64中,寄存器除了保存数据,还有函数传参的功能。过程调用传递参数规则:第1~6个参数一次储存在%rdi、%rsi、%rdx、%rcx、%r8、%r9这六个寄存器中,当超过6个参数时,剩下的参数保存在栈当中。
1、main函数:
参数传递:传入参数argc和argv[],分别用寄存器%rdi和%rsi存储。
函数调用:系统调用,实际上是启动代码调用。
局部变量:int i
函数返回:通过movl $0, %eax将%eax设置为0,返回0,对应return 0.
2、printf函数:
参数传递:这里有两个printf。第一处通过leaq .LC0(%rip), %rdi将唯一的一个参数字符串地址传给%rdi;第二处则有三个参数,第一个参数与上述方式一致,argv[1]与argc[2]则分别将首地址传给%rsi与%rcx。
函数调用:均由main函数调用。第一个printf在argc!=4时调用,第二个printf在每次for循环调用,汇编代码都是call puts@PLT。
局部变量:hello.s中展示不出
函数返回:hello.s中展示不出
3、exit函数:
参数传递:通过movl $1, %edi传给参数寄存器
函数调用:由main函数调用,当argc!=4时调用,call exit@PLT为调用的汇编命令。
局部变量:hello.s中展示不出
函数返回:hello.s中展示不出
4、sleep函数:
参数传递:call atoi@PLT以及movl %eax, %edi将atoi函数的返回结果作为参数传入sleep函数,也即是将%eax中的值复制给%edi。
函数调用:main函数调用,每次for循环调用,通过汇编语句call sleep@PLT调用。
局部变量:hello.s中展示不出
函数返回:hello.s中展示不出
5、atoi函数:
参数传递:通过以下语句:
addq $24, %rax
movq (%rax), %rax
movq %rax, %rdi
将argv[3]的字符串地址传入%rdi.
函数调用:main函数调用,每次for循环调用,通过汇编语句call atoi@PLT调用。
局部变量:hello.s中展示不出
函数返回:hello.s中展示不出
6、getchar函数:
参数传递:没有参数
函数调用:main函数调用,在main函数返回前调用,call getchar@PLT调用。
局部变量:hello.s中展示不出
函数返回:返回读取字符的ASCII
本章主要介绍了编译的概念以及过程,介绍了汇编代码如何实现变量、常量、传递参数以及分支和循环。编译程序所做的工作,就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码表示。实践了用Linux命令对hello.i实现编译。同时以hello.s为例,探究了C语言中各种数据类型以及操作在hello.s汇编文件中的表示,认识到了汇编语言的通用性,对语言的共性有了认识。
(第3章2分)
概念:汇编是将汇编代码转化成机器可以执行的命令,每一条汇编语句都对应一条机器指令,并生成可重定位目标程序的.o文件,该文件为二进制文件,字节编码时机器指令。在这里,汇编器(as)将hello.s翻译成机器语言指令,并将指令打包成可重定位目标程序hello.o。
作用:编译器根据根据汇编指令表和机器指令表一一进行翻译,把编译阶段生成的汇编文件转化成机器代码,生成目标文件(.o)[2].
(以下格式自行编排,编辑时删除)
应截图,展示汇编过程!
命令:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
截图:
图表 6 汇编结果
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析[3]。
首先用readelf -a hello.o > hello.elf指令生成hello.o的elf格式:
图表 7 生成.elf文件
除了以下的五个结构,hello.elf其实还包括了程序头,节组等信息,主要的五部分如下:
ELF头以一个16字节的序列开始(magic那一行),这个序列描述了生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器语法分析和解释目标文件的信息。包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
图表 8 ELF头
不同节的位置和大小是由节头部表描述的,其中目标文件每个节都有一个固定大小的条目。
可以直接看hello.elf也可以使用readelf命令,这里使用readelf命令:
图表 9 节头部表
一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
在这里,8 条重定位信息分别是对.L0(第一个 printf 中的字符串)、puts 函数、exit 函数、.L1(第二个 printf 中的字符串)、printf 函数、atoi函数、sleep 函数、getchar 函数进行重定位声明。
图表 10 .rela.text
这个section同.rel.text一样属于重定位信息的section,只不过它包含的是eh_frame的重定位信息,而eh_frame生成描述如何unwind 堆栈的表。
图表 11 .rela.eh_frame
.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
图表 12 符号表
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
使用objdump -d -r hello.o > Dhello.s分析hello.o的反汇编,并于第3章的hello.s进行对照分析。
图表 13 hello.o反汇编
与hello.s对照,发现由以下不同:
1、数制。在hello.s中数都是十进制,而反汇编Dhello.s中都是十六进制
图表 14 Dhello.s
图表 15 hello.s
2、分支跳转。在hello.s中,跳转指令的目标地址直接记为段名称,如.L2,.L3等。而在反汇编得到的Dhello.s中,跳转的目标为相对偏移的地址,也即间接地址。
图表 16 hello.s
图表 17 Dhello.s
3、函数调用。hello.s中,call指令使用的是函数名称,而反汇编Dhello.s中call指令使用的是main函数的相对偏移地址,并且下一行还有重定位信息,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
图表 18 hello.s
图表 19 Dhello.s
4、字符串(rodata)常量访问。在hello.s 文件中,使用段名称+%rip访问 rodata(printf 中的字符串),而在反汇编得到的Dhello.s中,使用 0+%rip进行访问。rodata 中数据地址在运行时才能确定,故访问时也需要重定位。在汇编成为机器语言时,将操作数设置为全 0 并添加相应的重定位条目。
图表 20 hello.s
图表 21 Dhello.s
本章对汇编阶段进行了详细的解读。通过汇编器以及Linux命令生成hello.s用readelf命令了解了ELF文件的基本格式,并且领会了书上与实际程序的相同与不同之处,对.o文件有了进一步了解。同时通过反汇编Dhello.s与hello.s文件的对比,了解了机器语言的构成,与汇编语言的映射关系。特别是发现了机器语言中的操作数与汇编语言不一致时的处理方式。总的来说对汇编有了全面深入探索。
(第4章1分)
注意:这儿的链接是指从 hello.o 到hello生成过程。
概念:链接是指将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译、加载、运行时。
作用:提供了一种模块化的方式,令源程序节省空间而未编入的常用函数文件(如printf.o)进行合并,生成可以正常工作的可执行文件。这令分离编译成为可能,节省了大量的工作空间。同时减少整体文件的复杂度与大小,增加容错性,也方便对某一模块进行针对性修改[2]。
命令: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/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
截图:
图表 22 链接命令
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息[3]。
首先用readelf -a hello > helloout.elf生成hello可执行文件的elf格式helloout.elf
图表 23 生成.elf文件
各段信息如下:
1、ELF头:
与前一部分ELF头基本一致,但类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。
图表 24 ELF头
2、节头部表:
与hello.elf相比,其在链接之后的内容更加丰富详细。记录了其各段的基本信息,包括各段的起始地址,大小等信息,这些信息如下:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 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 23 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
00000000000001f5 0000000000000000 AX 0 0 16
[16] .fini PROGBITS 00000000004012e8 000012e8
000000000000000d 0000000000000000 AX 0 0 4
[17] .rodata PROGBITS 0000000000402000 00002000
000000000000003b 0000000000000000 A 0 0 8
[18] .eh_frame PROGBITS 0000000000402040 00002040
0000000000000100 0000000000000000 A 0 0 8
[19] .init_array INIT_ARRAY 0000000000403e00 00002e00
0000000000000008 0000000000000008 WA 0 0 8
[20] .fini_array FINI_ARRAY 0000000000403e08 00002e08
0000000000000008 0000000000000008 WA 0 0 8
[21] .dynamic DYNAMIC 0000000000403e10 00002e10
00000000000001e0 0000000000000010 WA 7 0 8
[22] .got PROGBITS 0000000000403ff0 00002ff0
0000000000000010 0000000000000008 WA 0 0 8
[23] .got.plt PROGBITS 0000000000404000 00003000
0000000000000048 0000000000000008 WA 0 0 8
[24] .data PROGBITS 0000000000404048 00003048
0000000000000010 0000000000000000 WA 0 0 8
[25] .bss NOBITS 0000000000404058 00003058
0000000000000008 0000000000000000 WA 0 0 1
[26] .comment PROGBITS 0000000000000000 00003058
000000000000002b 0000000000000001 MS 0 0 1
[27] .symtab SYMTAB 0000000000000000 00003088
0000000000000630 0000000000000018 28 43 8
[28] .strtab STRTAB 0000000000000000 000036b8
0000000000000216 0000000000000000 0 0 1
[29] .shstrtab STRTAB 0000000000000000 000038ce
00000000000000fe 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)
3、程序头
描述了系统准备程序执行所需的段或其他信息。
图表 25 程序头
使用edb加载hello, Data Dump 窗口可以查看加载到虚拟地址中的 hello 程序。
图表 26 虚拟地址空间
由于段实在太多了,这里只展示几个对应关系:
Data dump一开始显示是0x401000,对照一下节头部表,发现是.init段:
图表 27 .init段
我们知道,main函数正是由init段里的启动代码所调用,因此这个对照关系是正确的。
然后,由计算机系统的特性,每一个进程的虚拟空间如下:
图26-1 进程虚拟空间
进程的虚拟内存地址由0x400000开始,切换到0x400000:
图表 28 0x400000
可以发现,0x400000开始所储存的内容正和ELF头的magic字节序列一致:
图表 29 Magic序列
字符串常量储存在rodata段,查看helloout.elf可以得知,.rodata段开始于0x402000,data dump中0x402000如下:
图表 30 .rodata
我们就可以看到两个字符串,因此这里也是一一对应的。
打开edb的symbolsviewer,与helloout.elf对比:
图表 31 对应关系
可以发现二者是完全一一对应的。说明了各段的虚拟地址与节头部表的对应关系。
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
利用objdump -d -r hello > Dhelloout.s生成反汇编文件
图表 32 hello反汇编
与hello.o分析比较如下:
1、链接后函数数量增加。链接后的反汇编文件Dhelloout.s中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。
图表 33 增加的函数
2、增加了一些节,增加了.init,.plt,第一点多出来的系统函数就是在这两个节中。
3.函数调用:hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
图表 34 Dhelloout.s
4、跳转指令参数发生变化。与函数调用类似,跳转地址变味了虚拟地址。在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
以下面的重定位条目为例,说明hello是如何对hello.o重定位:
图表 35 示例
首先,由重定位条目可以得知以下信息:
r.offset=0x1f
r.symbol=puts
r.type=R_X86_64_PLT32
r.addend=-0x4
CSAPP上讲的是R_X86_64_PC32,说的是利用与PC的相对地址计算重定位,这里利用的是R_X86_64_PLT32,其实计算过程是一样的。
ADDR(s)=0x4011d6
refaddr=ADDR(s)+r.offset=0x4011f5
*refaddr=(unsigned)(ADDR(r.symbol)+r.addend-refaddr)=(unsigned)(-0x169)
=0xfffffe97
在运行时,call指令调用PC值为0x4011fb,PC+0xfffffe95截断后得到0x401090,可以发现,正是hello运行时call的地址。
所以,一般重定位分为两步:
1、重定位节和符号定义
2、重定位节的符号引用,依靠重定位条目选择合适方式计算(R_X86_64_PC32或R_X86_64_32)
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
利用edb的symbol以及debug查看:
图表 36 edb窗口
子程序名 |
地址(16进制) |
ld-2.31.so!_dl_start |
0x7f92fcd0e100 |
ld-2.31.so!_dl_init |
0x7f92fcd1edf0 |
hello!_start |
0x4010f0 |
libc-2.31.so!__libc_start_main |
0x7f92fcb27fc0 |
hello!printf@plt(调用8次) |
0x4010a0 |
hello!atoi@plt(调用8次) |
0x4010c0 |
hello!sleep@plt(调用8次) |
0x4010e0 |
hello!getchar@plt |
0x4010b0 |
libc-2.31.so!exit |
0x7f92fcb4aa70 |
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
查看elf文件,发现以下信息:
图表 37 .got与.got.plt
打开edb跳转至0x404000,调用init前:
图表 38 init前
调用init后:
图表 39 init后
可以很明显地看出第1、2行的变化。
实际上,这是书上(P490)提到的动态链接器的延迟绑定的初始化部分。.plt 的作用简而言之就是先去 .got.plt 里面找地址,如果找的到,就去执行函数,如果是下一条指令的地址,说明没有,就会去触发链接器找到地址。延迟绑定通过全局偏移量表(GOT)和过程链接表(PLT)的协同工作实现函数的动态链接,其中GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
程序调用共享库函数时,会首先跳转到PLT执行指令,第一次跳转时,GOT条目为PLT下一条指令,将函数ID压栈,然后跳转到PLT[0],在PLT[0]再将重定位表地址压栈,然后转进动态链接器,在动态链接器中使用两个栈条目确定函数运行时地址,重写GOT,再将控制传递给目标函数。以后如果再次调用同一函数,则通过间接跳转将控制直接转移至目标函数[7]。
本章主要复习了链接的基本过程,通过对生成的可执行文件反汇编、查看elf文件等方式,从进程的虚拟内存空间角度,了解了静态链接与动态链接的不同,以及加深了对重定位计算方式的了解。不过我了解的也只是皮毛,链接远不止如此。
(第5章1分)
概念:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
作用:进程提供给应用程序两个关键抽象:
1、一个独立的逻辑控制流,他提供一个假象,好像我们的程序独占使用处理器;
2、一个私有的地址空间,他提供一个假象,好像我们的程序独占地使用内存系统。
作用:
Shell是信号处理的代表,负责各进程创建与程序加载运行及前后台控制,作业调用,信号发送与管理等。
Shell执行一系列的读/求值步骤然后终止。该步骤读取来自用户的一个命令行。求值步骤读取来自用户的一个命令行,求值步骤解析该命令行,并代表用户执行程序。在解析命令后,如果是内置命令,则解析指令并执行,否则就根据相关文件路径执行可执行目标文件。
处理流程:
命令行是一串 ASCII 字符由空格分隔。字符串的第一个单词是一个可执行程序,或者是 shell 的内置命令。命令行的其余部分是命令的参数。如果第一个单词是内置命令,shell 会立即在当前进程中执行。否则,shell 会新建一个子进程,然后再子进程中执行程序。新建的子进程又叫做作业。通常,作业可以由 Unix 管道连接的多个子进程组成。如果命令行以 &符号结尾,那么作业将在后台运行,这意味着在打印提示符并等待下一个命令之前,shell 不会等待作业终止。否则,作业在前台运行,这意味着 shell 在作业终止前不会执行下一条命令行。 因此,在任何时候,最多可以在一个作业中运行在前台。 但是,任意数量的作业可以在后台运行。
Unix shell 支持作业控制的概念,允许用户在前台和后台之间来回移动作业,并更改进程的状态(运行,停止或终止)。在作业运行时,键入 ctrl-c会将 SIGINT 信号传递到前台作业中的每个进程。SIGINT 的默认动作是终止进程。类似地,键入 ctrl-z 会导致 SIGTSTP 信号传递给所有前台进程。SIGTSTP 的默认操作是停止进程,直到它被 SIGCONT 信号唤醒为止。Unixshell 还提供支持作业控制的各种内置命令。
在目标文件夹打开shell,输入./hello 120L020318 syl 1,带参数执行可执行文件:
图表 40 运行hello
Shell利用fork创建进程过程如下:
首先,就像我们在实验四设计的那样,先判断是否是内置命令。由于这里是hello,因此不属于内置命令范畴。所以shell通过fork函数创建了一个子进程,这个子进程得到一份与父进程用户级虚拟空间相同且独立的副本——包括数据段、代码、共享库、堆和用户栈。父进程打开的文件,子进程也可读写,只有当子进程对数据做出更改时才会在内存中创建副本。二者之间最大的不同在于PID。Fork函数只会被调用一次,但会返回两次,在父进程中,fork返回子进程的PID,在子进程中,fork返回0,这个特性有时可以对子进程进行专属动作。
父进程也负责子进程的回收。如果子进程结束时父进程仍然存在,那么将有父进程进行回收;反之则有init进行回收,一切进程都是init的子进程。
在实践中,也常用拓扑图来描述fork:
图表 41 fork示意图
execve函数在当前进程的上下文中加载并运行一个新程序(hello),原型为:
int execve(const char *filename, const char *argv[], const char *envp[])
execve加载并运行可执行文件filename(hello),且带参数列表argv和环境变量列表envp。与fork不同,execve只有在找不到hello时才会返回。
在execve加载了hello之后,它利用启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下的原型:
int main(int argc , char *argv[] , char *envp[]);
execve函数的执行过程会覆盖当前进程的地址空间(映射私有区与共享区),但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。并且将PC指向代码区入口。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
1、逻辑控制流
一系列程序计数器 PC 的值的序列叫做逻辑控制流。hello进程与其他进程的执行时交错的。关键点在于hello进程与其他进程是轮流使用处理器的。每个进程执行他的流的一部分,然后被抢占(暂时挂起),然后轮到其他进程。对于运行在这些进程之一的上下文中的程序,看上去就像独占处理器,hello也不例外。
2、并发流
一个流在执行时间上与另一个流重叠,称为并发流或者并发进行,多个流并发执行的一般现象称为并发。一个进程和其他进程轮流运行的概念称为多任务,多任务也叫时间分片。hello进程的逻辑控制流是由一个(可能性很小)或多个时间分片组成的。
3、hello进程的私有地址空间
进程也为每个程序提供一种假象,好像独占使用系统地址空间。Hello进程也有他自己的私有地址空间,和这些地址相关联的那个内存字节是不能被除了hello以外的其他进程读写的。不过hello的私有地址空间结构与其他进程一致,如下图:
图表 42 进程的私有地址空间
4、用户模式和内核模式
为了保证系统安全,需要限制应用程序所能访问的地址空间范围。因而存在用户模式与内核模式的划分,内核拥有最高的访问权限,而用户态的访问权限会受到一些限制。处理器使用一个寄存器作为模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,或者使用/proc文件系统将一般信息送给进程,其他情况下始终处于用户权限之中,一定程度上保证了系统的安全性。
5、上下文切换
内核为hello进程维持了一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。内核是利用上下文切换机制来实现多任务并实现hello与其他进程的切换:1、保存当前hello进程上下文;2、恢复某个先前被抢占的进程保存的上下文;3、将控制传递给恢复的进程。
在hello进程执行过程中,会调用sleep函数。Sleep函数以秒作为单位,即使是1s对于计算机来说也是很大的开销。Sleep函数显式请求hello进程休眠,这是内核切换进程。当计时结束或收到信号时,sleep函数返回,触发一次CPU竞争,可能使得hello进程重新被调度,将其从等待队列中移出,并内核模式转为用户模式。此时 hello 进程就可以继续执行其逻辑控制流。
图表 43 上下文切换
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
1、正常运行状态
打印8次信息,期间sleep时间由我们决定,以输入任意字符作为结束。
图表 44 正常状态
2、运行期间按回车
这是一个异步中断,来自于运行期间不断敲入的回车(这里敲了3下),于是程序启动处理程序(实验四的parseline有关),完成后正常执行下一条指令,因此正常结束,中断处理如下:
图表 45 中断处理
同时,因为缓冲区留有未处理回车,程序结束后又多了三行回车。
图表 46 输入回车
3、乱按
乱按的字符串留到缓冲区,程序做出的反应是直接打印在屏幕上。同时如果期间输入回车,那么这之前到上一次输出信息的输入将会被视为命令行(与shell解析命令有关,实验四的eval函数)。
图表 47 乱按
4、ctrl+z
输入ctrl+z后我们可以发现不在输出信息,而是输出hello进程的状态与ID
图表 48 ctrl+z
输入jobs与ps确认只是被挂起,然后输入fg 1(1是jid)使其恢复,可以发现,他接着输出未完成的输出:
图表 49 查看进程与作业
5、ctrl+c
进程收到 SIGINT 信号,结束 hello。与ctrl+z形成对比:在ps中查询不到其PID,在job中也没有显示,可以看出ctrl+c让hello彻底结束。
图表 50 ctrl+c
6、kill
输入kill命令,则可以杀死指定(进程组的)进程:
图表 51 kill命令
7、pstree
输入pstree可以以树方式查看父子进程关系,可以看到hello进程也在其中:
图表 52 进程创建树
本章了解了hello进程的执行过程。通过一个简单的例子了解了一个进程的一生。了解到了进程是如何由shell诞生,在运行时是如果与其他进程交互,又是如何受到内核控制。同时,也初步探索了异常与信号处理机制,通过几种简单的信号,以及调用相信的发送信号指令、工具指令来认识计算机内核是如何应对他们。我们对hello执行过程中产生信号和信号的处理过程有了更多的认识,对使用linux调试运行程序也有了更多的心得。
(第6章1分)
逻辑地址
在有地址变换功能的计算机中,访内指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。也就是hello.o的.text的代码区中机器代码的操作数。
线性地址
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。对于hello来说,程序hello的代码会产生逻辑地址,或者说是(即hello程序)段中的偏移地址,它加上相应段的基地址就生成了一个线性地址。
虚拟地址
虚拟地址是程序运行在保护模式下,这样程序访问存储器所使用的逻辑地址称为虚拟地址。我们用edb执行hello时data dump所显示的地址就是虚拟地址,虚拟地址一般与进程的虚拟空间挂钩。
物理地址
在计算机科学中,物理地址(physical address),也叫实地址(real address)、二进制地址(binary address),它是在地址总线上,以电子形式存在的,使得数据总线可以访问主存的某个特定存储单元的内存地址。最终hello文件的虚拟地址需要通过访问页表来进行转换得到物理地址。当然上述是启用分页的情况,没有分页的话虚拟地址就是物理地址。
逻辑地址实际是由 48 位组成的,前 16 位包括“段选择符”后 32 位“段内偏移量”。前16位组成如下图:
图52-0 前16位组成
整体来讲,转换过程就是利用索引在GDT或者LDT中找到段描述符得到段基址,用段基址加上逻辑地址的段内偏移即可得到,如下图:
图52-1 寻找过程
补充说明下GDT以及LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。二者都是不可见的。详细功能如下:
IA-32架构下线性地址向物理地址的转换可以用下图表示:
图表 53 页式管理
这张图关键点如下:
1、由逻辑地址得到的线性地址一共 32 位。前 10 位是页目录索引,中间 10 位是页表索引,最后 12 位是业内偏移量
2、由 CR3 寄存器得到「页目录基地址」,再得到「页目录项」
3、由「页目录项」得到「页表基地址」
4、由「页表基地址」得到「页表项」,最后得到物理地址[6]。
我们csapp中一级页表的虚拟地址向物理地址转换也极为类似,因为虚拟地址空间一般也是线性地址空间:
每个进程都有一个页表。hello进程执行时,CPU中的页表基址寄存器指向hello进程的页表,当hello进程访问一个虚拟(线性)地址时,这个n位(Core i7为48位)的线性(虚拟)地址包含两部分:一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号。MMU(内存管理单元)利用虚拟页号(VPN)来选择适当的PTE(页表项),若PTE有效位为1,则说明其后内容为物理页号(PPN),否则缺页。而物理地址中低p位的物理页偏移量(PPO)与虚拟页偏移量(VPO)相同,PPN与PPO连接即得物理地址[1]。
7.3中阐述的一般针对一级页表,事实上,一级页表会造成极大的浪费,因此实际使用多级页表。同时TLB快表的存在也进一步加速了VA向PA的变换,整体过程如书上P577 core i7地址翻译:
图表 55 TLB与四级页表
如图所示, CPU产生虚拟地址VA,并将其传送至MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)在TLB中进行匹配,若命中,则得到PPN(40bit)与VPO(12bit)组合成物理地址PA(52bit)。若TLB没有命中,则MMU向页表中查询,由CR3确定第一级页表的起始地址。
与一级页表的VPN不同,多级页表的VA被分为多个VPN和一个VPO,高位VPN对于低级页表,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,则执行下一步确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并向TLB中添加条目[1]。图示如下:
图表 56 地址构成
经过7.4,我们已经将VA转换为PA,可以通过PA对内存进行访问。Intel core i7的三级cache结构如下:
图57-1 i7 cache结构
一般的cache组织结构如下:
图表 57 cache
PA访问内存的步骤如下:
(1) 根据PA、L1高速缓存的组数和块大小先后确定高速缓存块偏移(CO)、组索引(CI)和高速缓存标记(CT),最先使用CI进行组索引确定组号,对组中每行的标记与CT进行匹配。如果匹配成功且块的标志位为1,则命中,然后根据CO取出数据并返回数据给CPU。
(2) 若未找到相匹配的行或有效位为0,则L1未命中,继续在L2中进行类似过程的查找。若仍未命中,还要在L3高速缓存中进行查找。三级Cache均未命中则需访问主存或硬盘获取数据。
(3) 若进行了(2)步,说明至少有一级高速缓存未命中,则需在得到数据后更新未命中的Cache。首先判断其中是否有空闲块,若有空闲块(有效位为0),则直接将数据写入;若不存在,则需根据替换策略(如LRU、LFU策略等)驱逐一个块再写入。
下图展示了cache如何和虚拟内存结合起来。主要思路是地址翻译发生在cache查找之前[1]。
图表 58 全过程
当fork函数被shell调用时,内核为新进程(hello的前身)创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。正如第六章提到的,只有当这两个进程中的任一个后来进行写操作时,写时复制机制才就会创建新页面,才会在物理内存上有实际区别。因此,也就为每个进程保持了私有空间地址的抽象概念。
在bash中的进程中执行了如下的execve调用:
execve("hello",NULL,NULL)
execve函数在当前进程中加载并运行包含在可执行文件hello中的程序,用hello替代了当前bash中的程序。
加载并运行hello需要以下几个步骤:
1、删除已存在的用户区域。删除当前进程hello虚拟地址的用户部分中的已存在的区域结构。
2、映射私有区域。为新程序的代码、数据、bss和栈区域创建新的私有的、写时复制的区域结构。其中,代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
3、映射共享区域。若hello程序与共享对象或目标(如标准C库libc.so)链接,则将这些对象动态链接到hello程序,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器(PC)。exceve做的最后一件事是设置当前进程的上下文中的程序计数器,是指指向代码区域的入口点。
下一次调度这个进程时,他将从这个入口点开始执行。Linux将根据需要换入代码和数据页面[1]。加载器映射用户地址空间区域图示如下(hello地位等同于a.out):
图58-1 用户地址区域
缺页是常见的故障,有专门的处理程序,下图就是缺页处理流程:
图表 59 缺页故障处理示意图
页面命中完全由硬件处理,处理缺页则需要硬件和操作系统内核协作完成:
1. 处理器生成一个虚拟地址,并将它传送给MMU
2. MMU生成PTE地址,并从高速缓存/主存请求得到它
3. 高速缓存/主存向MMU返回PTE
4. PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
5. 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
6. 缺页处理程序页面调入新的页面,并更新内存中的PTE
7. 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中[1]。
动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存管理的基本方法与策略介绍如下:
1. 隐式链表
堆中的空闲块通过头部中的大小字段隐含地连接,分配器通过遍历堆中所有的块,从而间接遍历整个空闲块的集合。
对于隐式链表,其结构如下:
图表 60 隐式链表
2. 显式链表
在每个空闲块中,都包含一个前驱(pred)与后继(succ)指针,从而减少了搜索与适配的时间。
显式链表的结构如下:
图表 61 显式链表
3. 带边界标记的合并
采取使用边界标记的堆块的格式,在堆块的末尾为其添加一个脚部,其为头部的副本。添加脚部之后,分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态。从而实现快速合并,减小性能消耗。典型的四种情况如下:
图表 62 四种情况
4. 分离存储
维护多个空闲链表,其中,每个链表的块具有相同的大小。将所有可能的块大小分成一些等价类,从而进行分离存储。
5、分割或合并空闲块
6、获取额外堆内存[1]
本章详细阐述了hello在run阶段内存空间的使用情况。首先对四大地址概念做了一个分辨。然后对这四种地址之间是如何转换做了一个详细的解读,对段页式管理做了简单介绍,用hello这个实例,具有普遍性的介绍了cache与虚拟内存的实际使用。以及在虚拟内存视角下对fork和execve函数的映射关系有了全新理解,对于hello的诞生有了更清楚认识。缺页故障与缺页中断处理、动态存储分配管理。也都有介绍。
(第7章 2分)
设备的模型化:文件
设备管理:unix io接口
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前的文件位置、读写文件、关闭文件、每个进程开始时都有三个打开的文件:stdin,stdout,stderr[1]。
Unix IO接口:
1、打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息,应用程序只需要记住这个描述符。
2、Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件< unistd.h> 定义了常量STDIN_FILENO、STOOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
3、改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
4、读写文件:一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当 时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测这个条件。在文件末尾处并没有明确的“EOF符号”。类似地,写操作就是从内存复制 个字节到一个文件,从当前文件位置k开始,然后更新k。
5、关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix函数:
打开和关闭文件相关函数:
1、int open(char *filename, int flags, mode_t mode)
Open函数将filename转换为一个文件描述符,并且返回描述符数字,且总是在进程中当前没有打开的最小描述符。Flags参数指明了进程打算如何访问这个文件;mode参数指定了新文件的访问权限位。
2、int close(int fd)
Close函数用于关闭一个打开的函数,成功返回0;关闭一个已关闭的描述符会出错返回-1.参数fd是文件描述符。
读写文件相关函数:
3、ssize_t read(int fd, void *buf,size_t n)
Read函数从描述符为fd的当前文件位置复制最多n字节到内存位置buf。返回值-1表示一个错误,0表示EOF。否则,返回值是实际传送的字节数量。遇到EOF、从终端读取文本行或者读写网络套接字时可能会出现不足值,但这并不代表错误。
4、ssize_t write(int fd,const void *buf,size_t n)
Write函数从内存位置buf复制最多n字节到描述符fd的当前文件位置[1]。
改变当前文件位置:
5、off_t lseek(int fildes, off_t offset, int whence)
每一个已打开的文件都有一个读写位置, 当打开文件时通常其读写位置是指向文件开头, 若是以附加的方式打开文件(如O_APPEND), 则读写位置会指向文件尾. 当read()或write()时, 读写位置会随之增加,lseek()便是用来控制该文件的读写位置. 参数fildes 为已打开的文件描述词, 参数offset 为根据参数whence来移动读写位置的位移数[5].
首先看printf的代码:
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
在形参列表里有这么一个token:...,这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。这与OOP中的重载还有python的语法有些相似,和我们平时对printf的认知也符合。
很显然,我们需要一种手段,来让函数体可以知道具体调用时参数的个数。这一条语句给我们指明了答案(va_list是一个字符指针):
va_list arg = (va_list)((char*)(&fmt) + 4)
C语言中,参数压栈的方向是从右往左。也就是说,当调用printf函数的时候,先是最右边的参数入栈。fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。对于一个char *类型的变量,它入栈的是指针,而不是这个char *型变量。不过一开始有个纳闷的点,为什么他的指针大小是4,当看到了发表日期后我明白了…
图表 63 参考文献发表日期
接近十年前应该是32位计算机,所以指针大小是4,所以 (char*)(&fmt) + 4) 表示的是...中的第一个参数的地址。
接下来看vsprintf函数:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
Fmt是format的缩写,当没有碰到%时,就将fmt的字符复制到buf中;一旦碰到,则将args参数指向的内存空间的值赋值给buf,就是我们认知中将%x,%s换成对应数值或串。然后返回的值就是写指针相对初始的偏移,故返回的是写的长度。
接下来看write底层实现:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
传输完对应参数后,以int INT_VECTOR_SYS_CALL结束,那么玄机肯定藏在这之中,因为这之前都只是做着传参的工作。看一看实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
简单说来就是一个陷阱,触发处理程序。利用字符显示驱动子程序实现从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。控制显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
至此,printf就完成了向屏幕输出[8]。
getchar()函数实际上是int getchar(void),所以它返回的是ASCII码,所以只要是ASCII码表里有的字符它都能读取出来。在调用getchar()函数时,编译器会依次读取用户键入缓存区的一个字符(注意这里只读取一个字符,如果缓存区有多个字符,那么将会读取上一次被读取字符的下一个字符),如果缓存区没有用户键入的字符,那么编译器会等待用户键入并回车后再执行下一步 (注意键入后的回车键也算一个字符,输出时直接换行)。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ASCII码,直到接受到回车键才返回。
本章介绍了 Linux 的 I/O 设备的基本概念和管理方法,以及Unix I/O 接口及其函数。最后分析了printf 函数和 getchar 函数的工作过程。了解了众多IO函数是如何基于最基本的unix IO 进行编写。
(第8章1分)
Hello.c进化成可执行文件hello经历了层层关卡:
而hello的实际执行离不开操作系统的调度以及硬件的配合:
1、shell bash调用fork函数,生成子进程;这个子进程调用execve函数在当前进程的上下文中加载并运行新程序hello
2、hello的变化过程中,会有各种地址,但最终我们真正期待的是PA物理地址,可执行文件中VA需要通过TLB以及页表机制进行转换;而PA的访问也需要内存层次结构的支持才得以快速运行。
3、hello再运行时会调用一些函数,比如printf函数,这些函数与linux I/O的设备模拟化密切相关
4、hello最终被shell父进程回收,内核会收回为其创建的所有信息,hello完成了他的使命。
CSAPP是计算机书籍中极为经典的著作,介绍了计算机系统的基本概念,包括最底层的内存中的数据表示、流水线指令的构成、虚拟存储器、编译系统、动态加载库,以及用户应用等。书中提供了大量实际操作,可以帮助读者更好地理解程序执行的方式,改进程序的执行效率。此书以程序员的视角全面讲解了计算机系统,深入浅出地介绍了处理器、编译器、操作系统和网络环境。内容很多,同时也为今后408课程的进一步学习打下基础。
What is a programmer?互联网行业大力发展的今天,许多人误将码农当作真正的programmer,殊不知,一个合格的程序员不能仅仅盯着算法以及库是怎么实现的,更应该着眼于计算机底层。所有技术的革新首先是基于硬件条件与软件底层,我们不应眼高手低,只关注上层而忽略底层。HITer应该要有这个觉悟。
(结论0分,缺失 -1分,根据内容酌情加分)
列出所有的中间产物的文件名,并予以说明起作用。
文件名 |
作用 |
hello.i |
hello.c预处理后得到的文件 |
hello.s |
编译后的汇编语言文件 |
hello.o |
汇编后得到的可重定位目标文件 |
hello.elf |
用readelf读取hello.o得到的ELF格式信息 |
Dhello.s |
反汇编hello.o得到的反汇编文件 |
helloout.elf |
由hello可执行文件生成的.elf文件 |
Dhelloout.s |
反汇编hello可执行文件得到的反汇编文件 |
hello |
最终链接得到的可执行文件 |
图表 64 中间产物
(附件0分,缺失 -1分)
为完成本次大作业你翻阅的书籍与网站等
[1] 《深入理解计算机系统》 Randal E.Bryant David R.O’Hallaron 机械工业出版社
[2]《gcc--编译的四大过程及作用》https://blog.csdn.net/shiyongraow/article/details/81454995
[3] 《深入理解ELF文件》https://blog.51cto.com/u_12444109/3026869
[4] 《段页式访存——逻辑地址到线性地址的转换》https://www.jianshu.com/p/fd2611cc808e
[5]《C语言lseek()函数:移动文件的读写位置》http://c.biancheng.net/cpp/html/236.html
[6] 《段页式访存——线性地址到物理地址的转换》https://www.cnblogs.com/pipci/p/12404023.html
[7] 《深入了解GOT,PLT和动态链接》https://www.cnblogs.com/pannengzhi/p/2018-04-09-about-got-plt.html
[8] 《printf 函数实现的深入剖析》https://www.cnblogs.com/pianist/p/3315801.html
(参考文献0分,缺失 -1分)