摘要
Hello.c作为一个一个入门级的程序,它的执行包含着计算机系统各个环节的相互配合。本篇课程报告主要以linux系统下,hello.c从C语言程序经过预处理、编译、汇编、链接生成可执行目标文件hello的过程,以及通过进程管理、存储管理以及IO管理,分析了hello是如何在计算机系统中执行的。
关键词:计算机系统;P2P;020;
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
1.1.1 P2P
hello程序的生命周期是从一个高级C语言程序开始的。为了在系统上运行hello.c程序,hello.c首先经过预处理器(cpp)得到修改了的源程序hello.i;接着,编译器(cc1)将其翻译为汇编程序hello.s;然后经过汇编器(as)翻译成机器语言指令,把这些指令打包成可重定位目标程序hello.o;接下来,经过链接器,将调用的标准C库中的函数(如printf等)对应的预编译好了的目标文件以某种方式合并到hello.o文件中,得到可执行目标程序hello。当我们运行时,在shell中利用fork()函数创建子进程,再用execve加载hello程序,这时,hello就由程序(program)变成了一个进程(process),完成了P2P的过程。
1.1.2 020
020:在shell中用fork()函数创建子进程,再用execve加载可执行目标程序hello,映射虚拟内存,程序开始时载入物理内存,进入CPU处理。CPU为执行文件hello分配时间片,进行取指、译码、执行等流水线操作。内存管理器和CPU在执行过程中通过L1、L2、L3三级缓存和TLB多级页表在物理内存中取的数据,通过I\O系统根据代码指令进行输出。在程序运行结束后,父进程会对其进行回收,内核把它从系统中清除,这时hello就由0转换为0,完成了020的过程。
1.2 环境与工具
1.2.1 硬件环境
处理器 12th Gen Intel(R) Core(TM) i5-12500H 2.50 GHz
机带RAM 16.0 GB (15.7 GB 可用)
系统类型 64 位操作系统, 基于 x64 的处理器
硬盘 476GB SSD
1.2.2 软件环境
Windows 11 家庭中文版
Vmware 16.2.1;Ubuntu 20.04.3 LTS 64位;
1.2.3 开发工具
Visual Studio 2022 64位;CodeBlocks 64位;vi/vim/gedit+gcc;
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 作用 |
hello.c | 原C语言文件 |
hello.i | 预处理产生文件 |
hello.s | 编译产生文件 |
hello.o | 汇编产生文件 |
hello.out | 链接产生可执行文件 |
hello_o_elf.txt | 查看hello.o的elf格式对应的文本文件 |
hello_o_asm.txt | 查看hello.o的反汇编对应的文本文件 |
hello_elf.txt | 查看hello的elf格式对应的文本文件 |
hello_asm.txt | 查看hello的反汇编对应的文本文件 |
1.4 本章小结
本章主要介绍了hello程序的P2P(From Program to Process) 及020(From Zero-0 to Zero-0)的过程。
同时介绍了此次大作业完成的硬件环境、软件环境以及开发工具。
还列出了为完成本次大作业,生成的中间结果文件的名字以及文件的作用。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理是指预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第一行中的 #include <stdio.h> 命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果是得到了另一个C程序,通常以.i作为文件扩展名。
2.1.2 预处理的作用
预处理可以处理C语言中以#开头的语句,大致包括以下几类:
- #define:进行宏替换,用实际的常量或字符串常量来替换它的符号;
- #include:处理文件包含,将包含的文件插入到程序文本中;
- #if、#elif、#else等:条件编译,选择符合条件的代码送至编译器编译,实现有选择地执行相关操作
- 注释:删除C语言源程序中所有的注释;
- #error等:特殊控制指令。
2.2在Ubuntu下预处理的命令
在Ubuntu下预处理的命令为:
cpp hello.c > hello.i
预处理过程如下:
图2.2-1 预处理过程
预处理后生成的文件hello.i如下:
图2.2-2 预处理后生成的文件
2.3 Hello的预处理结果解析
打开hello.i文件,将其与hello.c文件进行对比。发现hello.c文本仅有23行,而hello.i文件有3060行,代码量大大增加。经过对比发现,hello.i中的main函数(除注释与头文件)部分位于代码的最后,与源程序中的main函数相同,没有改变。
图2.3-1 hello.c 与 hello.i 对比的相同部分
对比发现在两个文本文件的不同部分中,首先是注释部分被删除了,并且插入了大量的代码。其中有在源程序中被引用的stdio.h,unistd.h,以及stdlib.h的代码,它们都是被直接插入了程序文本中。
图2.3-2 插入的stdio.h
图2.3-3 插入的unistd.h
图2.3-4 插入的stdlib.h
在hello.i文本文件中,还发现插入了一些未在hello.c中直接引用的其他文件。经过分析,这些文件应该是在stdio.h等文件中引用了,被递归插入了hello.i中。
图2.3-5 hello.i中插入的其他文件
也就是说在hello.c中引用的头文件,可能会引用另一些文件,他们在预处理的过程中都需要被展开插入到hello.i文件中,使得文件代码量相比源程序大大增加。
2.4 本章小结
本章主要介绍了预处理的概念以及预处理在五个方面的作用与功能,包括进行宏替换、处理文件包含,进行条件编译、删除注释以及处理#error等特殊控制指令。
在Ubuntu下将hello.c文件预处理生成了hello.i文件。
分析了hello.i文件与源程序hello.c文件的相同与不同之处。发现预处理的过程会保留注释与#开头语句之外的部分,删除注释部分,同时将头文件与头文件所包含的文件直接插入到代码中。
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译是指将便于人编写、阅读、维护的高级计算机语言所写作的源代码程序,翻译为计算机能解读、运行的低阶机器语言的程序。在这里指编译器(ccl)将文本文件hello.i翻译成汇编语言程序hello.s的过程。
3.1.2 编译的作用
编译的作用是将高级计算机语言所写作的源代码程序翻译为汇编语言程序,在这个过程中,会进行以词法分析、语法分析、语义分析来生成汇编语言程序,且编译器可能在这个过程中根据编译选项对程序进行一些适当的优化。
3.2 在Ubuntu下编译的命令
在Ubuntu下编译的命令为:
cc1 hello.i -o hello.s
由于在我的Ubuntu中,cc1不在path中,这里使用命令:
/usr/lib/gcc/x86_64-linux-gnu/9/cc1 hello.i -o hello.s
编译过程如下:
图3.2-6 编译过程
编译后生成的文件hello.s如下:
图3.2-7 编译后生成的文件
3.3 Hello的编译结果解析
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.3.1 数据
1. 数字常量
在hello.c中出现的数字常量在hello.s中都有相应的对应。编译器将数字常量以立即数的形式进行处理。
- 将整型数argc与4比较,数字常量4在hello.s中以立即数$4的形式出现。
图3.3-1 hello.c中出现的第1处数字常量
图3.3-2 编译器将第1处数字常量处理为立即数
- 在循环的判断条件中出现数字常量,用立即数表示。注意到由于循环的判断条件为i<8,编译器将其翻译为小于等于立即数$7。
图3.3-3 hello.c中出现的第2处数字常量
图3.3-4 编译器将第2处数字常量处理为立即数
2. 字符串常量
在hello.c文件中,有两个字符串,如下图所示:
图3.3-5 hello.c的两个字符串常量
编译器对这两句字符串进行处理时,将这两个字符串放入内存中的 .rodata节常量区中,如下图所示:
图3.3-6 两个字符串常量存放在 .rodata节中
打印字符串常量时,编译器将语句翻译为先将字符串存放的地址存入寄存器%rdi,再进行打印,具体过程如下图所示:
图3.3-7 打印字符串常量
3. 局部变量
编译器一般将局部变量存放在寄存器中或者栈中,存在寄存器中时可以看作是寄存器别名,节省从内存中读取数据的时间。
- 传入参数int argc存放在寄存器%edi中。
图3.3-8 传入参数argc
- 传入参数char *argv[]存放在栈中,其中首地址存放在栈中-32(%rbp)的位置,argv[1]地址为-32(%rbp)+$8,argv[2]地址为-32(%rbp)+$16,argv[3]地址为-32(%rbp)+$24。在hello.s中的使用如下图所示:
图3.3-9 传入参数char *argv[]
- 局部变量int argc存放在栈中-20(%rbp)的位置。
图3.3-10 局部变量int argc
- 局部变量int i存放在栈中-4(%rbp)的位置。
图3.3-11 局部变量int i
3.3.2 赋值
编译器将赋值的操作主编为对相应的寄存器或栈进行赋值。
在hello. c文件中,有对i的赋值如下:
图3.3-12 hello.c中对局部变量i赋值
经过编译器cc1的编译,在hello.s文件中,该语句转变为:
图3.3-13 hello.s中对局部变量i赋值
在3.3.1中,已经知道栈中-4(%rbp)的位置存放的是局部变量i,因此是将i赋值为0。
3.3.3 类型转换
Hello.c文件中, argv[3]的类型为字符型,经过函数atoi()转换为整型。在hello.c文件中的实现如下图所示:
图3.3-14 hello.c中调用函数实现类型转换
经过编译器编译后,步骤变为首先将argv[3]从栈中取出,赋值给%rdi,通过调用call atoi@PLT指令调用atoi函数,最终转换为整型数,存放在%eax中。
图3.3-15 hello.s中调用函数实现类型转换
3.3.4 算术操作
Hello.c文件中,算术运算有for循环中的i++:
图3.3-16 hello.c中的i++
经过编译器后,被翻译为:
图3.3-17 hello.s中的i++
在3.3.1中,已经知道栈中-4(%rbp)的位置存放的是局部变量i,每次执行这条指令,实现的是i自增1。
3.3.5 关系操作
对于关系操作,编译器一般会将关系操作翻译为cmp语句。在源文件hello.c中,有两处关系操作:
- 比较argc与4是否相等
图3.3-18 hello.c中比较argc与4是否相等
经过编译,在hello.s文件中,该关系比较实现为:
图3.3-19 hello.s中对argc与4的关系操作
在3.3.1中,已经知道栈中-20(%rbp)的位置存放的是argc,因此这条指令就是在判断4与argc的关系。
- 比较i与8的大小,i >= 8时跳出循环,在hello.c中的实现为:
图3.3-20 hello.c中比较i与8的大小
经过编译,在hello.s文件中,该关系比较实现为:
图3.3-21 hello.s中对i与8的关系操作
在3.3.1中,已经知道栈中-4(%rbp)的位置存放的是i,并且这里将i<8替换为判断i<=7,意义与原C语言程序相同。
3.3.6 数组/指针/结构操作
编译器对源代码中数组的操作往往翻译为对地址的加减操作,在hello.c的源代码中,存在对数组argv[]的访问:
图3.3-22 hello.c中的数组操作
经过编译器的翻译,在hello.s的文件中,对数组argv的访问变为:
图3.3-23 hello.s中的数组操作
其中首地址存放在栈中-32(%rbp)的位置,argv[1]地址为-32(%rbp)+$8,argv[2]地址为-32(%rbp)+$16,argv[3]地址为-32(%rbp)+$24即进行了地址的加减操作以访问数组。
3.3.7 控制转移
控制转移是指C语言源文件中的选择分支、循环结构等经过编译器的翻译,产生一些跳转的语句,编译器cc1编译后的文件hello.s中,控制转移有三处:
- 比较argc是否等于4,如果相等,跳转.L2,否则顺序执行。
图3.3-24 hello.s中的第一处控制转移
- 无条件跳转,将i初始化为0后,无条件跳转至循环中。
图3.3-25 hello.s中的第二处控制转移
- 比较i是否小于等于7(小于8),如果小于等于7,则跳转至.L4,即满足循环条件,继续循环;如果大于7,则顺序执行,跳出循环。
图3.3-26 hello.s中的第三处控制转移
3.3.8 函数调用
函数调用一般会进行参数传递和返回值。在hello.s中,一共有六次函数调用。
- 调用puts()函数。首先将调用函数所需要的参数,即需要打印的字符串常量的地址存放在寄存器%rdi中,然后执行call puts@PLT指令打印字符串。对应的C语言源程序如下:
图3.3-27 hello.c中第一处函数调用
可以看到,在C语言源程序中执行该命令用的是printf函数,由于打印的是一个单纯的字符串,因此编译器对它进行了优化,改用puts函数进行打印。相应的hello.s文件中的指令如下:
图3.3-28 hello.s中第一处函数调用
- 调用exit()函数,参数为1。对应的C语言源程序如下图所示:
图3.3-29 hello.c中第二处函数调用
在hello.s中,首先进行参数的准备。将立即数1放入寄存器%edi中,然后执行call exit@PLT指令调用exit函数。
图3.3-30 hello.s中第二处函数调用
- 调用含其他参数的printf()函数。对应的C语言源程序如下图所示:
图3.3-31 hello.c中第三处函数调用
在hello.s中,首先进行参数的准备。将argv[2]放入寄存器%rdx中,将argv[1]放入寄存器%rsi中,将字符串"Hello %s %s\n"放入寄存器%rdi中,然后执行call printf@PLT指令进行打印。
图3.3-32 hello.s中第三处函数调用
- 调用atoi()函数,参数为argv[3],以下是C语言原程序中的函数调用:
图3.3-33 hello.c中第四处函数调用
在hello.s中,首先进行参数的准备。将argv[3]放入寄存器%rdi中,然后执行call atoi@PLT指令调用atoi函数。
图3.3-34 hello.s中第四处函数调用
- 调用sleep()函数,参数为atoi(argv[3])的返回值,以下是C语言原程序中的函数调用:
图3.3-35 hello.c中第五处函数调用
在hello.s中,首先进行参数的准备。将atoi(argv[3])的返回值放入寄存器%edi中,然后执行call sleep@PLT指令调用sleep函数。
图3.3-36 hello.s中第五处函数调用
- 调用getchar()函数,没有参数,以下是C语言原程序中的函数调用:
图3.3-37 hello.c中第六处函数调用
在hello.s中,直接执行call getchar@PLT指令进行函数的调用。
图3.3-38 hello.s中第六处函数调用
3.4 本章小结
本章主要介绍了编译的的概念以及编译的作用与功能,包括将高级语言指令翻译为汇编语言指令,同时根据编译选项进行一些适当的优化。
在Ubuntu下将hello.i文件编译生成了hello.s文件。
按照C语言的不同数据与操作类型,分析了源程序hello.c文件中的语句是怎样转化为hello.s文件中的语句的。其中数据类型包括数字常量、字符串常量和局部变量;操作类型包括赋值、类型转换、算术操作、关系操作、数组\指针\结构操作控制转移以及函数调用。
第4章 汇编
4.1 汇编的概念与作用
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
3.1.1 汇编的概念
汇编是指将汇编语言程序经过编译器(as)转化为二进制的机器语言指令,并把这些指令打包成可重定位目标程序的格式,并保存在目标文件.o中。在这里指汇编器器(as)将文本文件hello.s转换为可重定位目标程序hello.o的过程。
3.1.2 汇编的作用
汇编的作用是把汇编语言翻译成机器语言,用二进制码0、1代替汇编语言中的符号,即让它成为机器可以直接识别的程序。最后把这些指令打包成可重定位目标程序的格式,并保存在目标文件.o中。
4.2 在Ubuntu下汇编的命令
在Ubuntu下汇编的命令为:
as hello.s -o hello.o
汇编过程如下:
图4.2-1 汇编过程
汇编后生成的文件hello.o如下:
图4.2-2 汇编后生成的hello.o文件
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先查看hello.o的ELF格式,在终端中输入如下指令即可查看:
readelf -a hello.o
也可以用以下的语句将输出内容保存为文本文件再进行查看:
readelf -a hello.o > hello_o_elf.txt
4.3.1 ELF头
hello.o的ELF格式的开头是ELF头,它以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括ELF头的大小、目标文件的类型、处理器体系结构、节头部表的文件偏移,以及节头部表中条目的大小和数量。
图4.3-1 hello.o的ELF格式的ELF头
4.3.2 节头部表
ELF文件格式中的节头部表描述了目标文件中不同节的类型、地址、大小、偏移等信息,以及可以对各部分进行的操作权限。
图4.3-2 hello.o的ELF格式的节头部表
4.3.3 重定位节
ELF文件格式中的重定位节包含两个部分:.rela.text节与.rela.eh_frame节。
图4.3-3 hello.o的ELF格式的重定位节
.rela.text节包含.text节中的位置的列表,含有该.text中所需要进行重定位操作的信息,当链接器(ld)将目标文件与其他文件由进行结合时,需要修改这些位置
.rela.eh_frame节包含了对en_frame节的重定位信息。
在.rela.text节与.rela.eh_frame节中,表头表头名称与对应的含义如下表所示:
表头名称 | 含义 |
偏移量 | 需要重定位的信息的字节偏移位置 (代码节/数据节) |
信息 | 重定位目标在.symtab中的偏移量和重定位类型 |
类型 | 表示不同的重定位类型 (例如图中R_X86_64_PC32就表示重定位一个使用32位PC相对地址的引用) |
符号名称 | 被重定位时指向的符号 |
加数 | 重定位过程中要使用它对被修改引用的值做偏移调整 |
4.3.3 符号表
ELF文件格式中的符号表中存放了程序中所定义和引用的的全局变量以及函数的信息。(不包含局部变量)
图4.3-4 hello.o的ELF格式的符号表
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
输入以下命令查看hello.o对应的反汇编代码:
objdump -d -r hello.o
也可以用以下的语句将输出内容保存为文本文件再进行查看:
objdump -d -r hello.o > hello_o_asm.txt
得到反汇编代码如下:
图4.4-1 hello.o对应的反汇编代码
可以看见hello.o对应的反汇编代码每一句都有一串相应的地址与二进制的指令,以十六进制的数值展示出来。
对比hello.o的反汇编代码,与hello.s的内容进行比较,发现以下几点的不同:
4.4.1 数字进制不同
hello.s中操作数都是十进制的,而在反汇编代码中以十六进制表示,这对应着在机器中以二进制的形式存在。
图4.4-2 hello.s中的十进制与hello.o反汇编代码中的十六进制
4.4.2 对字符串常量的引用不同
hello.s中是用的全局变量所在的那一段的名称加上%rip的值,而hello.o中用的是0加%rip的值,因为当前为可重定位目标文件,之后还需经过重定位方可确定其具体位置,所以这里都用0来代替。
图4.4-3 hello.s中字符串常量与hello.o反汇编代码中的字符串常量
4.4.3 分支转移的不同
hello.s中列出了每个段的段名,分支转移时,跳转指令后用对应的段的名称表示跳转位置;而在hello.o的反汇编代码中每个段都有明确的地址,跳转指令后用相应的地址表示跳转位置。
图4.4-4 hello.s与hello.o反汇编代码中分支转移的不同
值得注意的是,在机器代码中,采用的是相对寻址方式。如:
这行代码在机器的二进制代码中跳转的是0x48。因此实际跳转的位置是下一条指令的地址0x34+0x48=0x7c的位置,如果是后向跳转,则会加上一个负值。
4.4.4 函数调用的不同
在hello.s中调用函数时,在call指令之后直接引用函数名称,而在hello.o的反汇编代码中,在call指令后加上下一条指令的地址来表示。观察机器语言,发现其中操作数都为0,这是因为通过重定位信息,再链接生成可执行文件后才会生成其确定的地址,所以这里的相对地址都用0代替。
图4.4-5 hello.s与hello.o反汇编代码中函数调用的不同
4.5 本章小结
本章主要介绍了汇编的的概念以及汇编的作用,主要是将汇编语言程序经过编译器(as)转化为二进制的机器语言指令,并把这些指令打包成可重定位目标程序的格式,并保存在目标文件.o中,成为机器可以识别的程序。
在Ubuntu下将hello.s文件经过汇编器(as)生成了hello.o文件。
分析了hello.o的ELF格式,用readelf等列出了其各节的基本信息,包括ELF头、节头部表、重定位节以及符号表的功能与包含的信息。
对hello.o二进制文件进行反汇编,得到了反汇编程序,并分析了该反汇编程序与汇编语言程序hello.s中语句的对应关系。从数字进制、字符串常量的引用、分支转移以及函数调用的不同四个方面分析了二者的关系。
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.1.1 链接的概念
链接(linking)是指将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
5.1.2 链接的作用
链接的作用是将预编译好了的一个目标文件(hello.o)或若干目标文件外加链接库合并成为一个可执行目标文件(hello)。使得分离编译称为可能,不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为可独立修改和编译的模块。当改变这些模块中的一个时,只需简单重新编译它并重新链接即可,不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
在Ubuntu下链接的命令为:
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
链接过程如下:
图5.2-1 链接过程
汇编后生成的可执行目标文件hello如下:
图4.2-2 汇编后生成的hello.o文件
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
首先查看hello的ELF格式,在终端中输入如下指令即可查看:
readelf -a hello.o
也可以用以下的语句将输出内容保存为文本文件再进行查看:
readelf -a hello > hello_elf.txt
通过查看hello文件的elf格式的节头,列出其各段的基本信息如下:
图5.3-1 可执行目标文件hello的格式
其中,它的第一列按地址顺序列出了各段的名称及大小,第三列列出来各段的起始地址,最后一列列出来各段的偏移量
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
首先用edb加载hello,在终端输入如下命令:
edb --run hello
完成加载。
图5.4-1 用edb加载hello
通过readelf查看hello的程序头,可以发现其中的虚拟地址在edb的Data Dump中都能找到相应的位置,且大小也相对应。
- 在edb的symbol窗口,可以查看各段对应的名称以及各段的起始位置与结束的位置,与5.3中所展示出来的elf格式展示出来的相对应。
图5.4-2 各段名称、起始位置和终止位置对应
- Data Dump是从地址0x400000开始的,并且该处有ELF的标识,可以判断从可执行文件加载的信息(只读代码段,读/写段)是从地址0x400000处开始的。可以从程序头处读取相关信息。
图5.4-3 可执行文件加载信息起始位置
- .PDHR起始位置为0x400040 大小为0x230。
图5.4-4 .PDHR 起始位置与大小
- .INTERP起始位置为0x400270 大小为0x1c。
图5.4-5 .PDHR 起始位置与大小
- .DYNAMIC起始位置为0x403e10 大小为0x1e0
图5.4-6 .DYNAMIC起始位置与大小
- .GNU_RELRO起始位置为0x403e50 大小为0x1b0
图5.4-7.GNU_RELRO起始位置与大小
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
首先查看hello的反汇编代码,在终端中输入如下指令即可查看:
objdump -d -r hello
也可以用以下的语句将输出内容保存为文本文件再进行查看:
objdump -d -r hello > hello_asm.txt
将hello.o与hello文件的反汇编代码进行对比分析,得到如下几方面的不同。
5.5.1 代码量增加
打开反汇编代码的文本文件,查看两个文件代码量,发现hello_o_asm.txt只有52行,而hello_asm.txt有223行。
图5.5-1 经过链接后的反汇编代码量增加
5.5.2 插入C标准库中的代码
在hello.o的反汇编程序中,只有main函数,没有调用的函数段;经过链接过程后,原来调用的C标准库中的代码都被插入了代码中,并且每个函数都被分配了各自的虚拟地址。
图5.5-2 被插入的函数与其虚拟地址
5.5.3 指令分配虚拟地址
在hello.o的反汇编程序中, main函数中的所有语句前面的地址都是从main函数开始从0开始依次递增的,而不是虚拟地址;经过链接后,每一条语句都被分配了虚拟地址。
图5.5-3 链接前后的main函数语句的地址
5.5.4 字符串常量的引用
在hello.o的反汇编程序中,字符串常量的位置是用0加%rip的值来表示的,这是由于当时字符串常量并未分配虚拟内存;而在hello的反汇编程序中,因为字符串常量都有了相应的位置,所以用实际的相对下一条语句的偏移量加%rip(下一条语句的地址)的值来描述其位置。
图5.5-4 链接前后字符串常量的引用
5.5.5 函数调用
在hello.o的反汇编程序中,由于当时函数未被分配地址,所以调用函数的位置都用call加下一条指令地址来表示;而在hello的反汇编程序中,由于各函数已拥有了各自的虚拟地址,所以在call后加其虚拟地址来实现函数调用。
图5.5-5 链接前后的函数调用
5.5.6 跳转指令
在hello.o的反汇编程序中,对于跳转指令,在其后加上目的地址,为main从0开始对每条指令分配的地址;而在hello的反汇编程序中,由于各语句拥有了各自的虚拟地址,所以同样加上目的地址,但这里是每条指令的虚拟地址。
图5.5-6 链接前后的跳转指令
5.5.7 链接的过程分析
链接的过程主要分为符号解析和重定位这两个过程。
1)符号解析:符号解析解析目标文件定义和引用符号,并将每个符号引用和一个符号定义相关联。
2)重定位:编译器和汇编器生成从0开始的代码和数据节。而链接器通过把每个符号定义与一个虚拟内存地址相关联,从而将这些代码和数据节重定位,然后链接器会修改对所有这些符号的引用,使得它们指向这个虚拟内存地址。
对于hello来说,链接器把hello中的符号定义都与一个虚拟内存位置关相关联,重定位了这些节,并在之后对符号的引用中把它们指向重定位后的地址。hello中每条指令都对应了一个虚拟地址,而且对每个函数,全局变量也都它关联到了一个虚拟地址,在函数调用,全局变量的引用,以及跳转等操作时都通过虚拟地址来进行,从而执行这些指令。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
从加载hello到_start,到call main,以及程序终止的所有过程如下:
_dl_start 地址:0x7f894f9badf0
_dl_init 地址:0x0x7f894f9cac10
_start 地址:0x401090
_libc_start_main 地址:0x7fce59403ab0
_cxa_atexit 地址:0x7f38b81b9430
_libc_csu_init 地址:0x4005c0
_setjmp 地址:0x7f38b81b4c10
_sigsetjmp 地址:0x7efd8eb79b70
_sigjmp_save 地址:0x7efd8eb79bd0
main 地址:0x401176
(argc!=3时
puts 地址:0x401030
exit 地址:0x401070
此时输出窗口打印出“用法: Hello 学号 姓名 秒数!”,程序终止。)
print 地址:0x401040
sleep 地址:0x401080 (以上两个在循环体中执行8次)
此时窗口打印8行“Hello 7203610702 袁艺铭”
getchar 地址:0x4004d0
等待用户输入回车,输入回车后:
_dl_runtime_resolve_xsave 地址:0x7f5852241680
_dl_fixup 地址:0x7f5852239df0
_uflow 地址:0x7f593a9a10d0
exit 地址:0x7f889f672120
程序终止。
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
当程序调用一个共享库的函数时,编译器不能预测这个函数在什么地址,因为定义它的共享模块在运行时可以加载到任何位置。这时,编译系统提供了延迟绑定的方法,即:将过程地址的加载推迟到第一次调用该过程时。通过观察edb对hello的执行情况,便可发现dl_init前后后.got.plt节发生的变化。
首先,通过readelf找到.got.plt节在地址为0x404000的地方开始,大小为0x48。因此,结束地址为0x40400047,这两个地址之间部分便是.got.plt的内容。
5.7-1 .got.plt节在虚拟内存中的位置
在edb中的Data Dump中找到这个地址,观察.got.plt节的,发现在dl_init前后,.got.plt的第8到15个字节发生了变化。
在这里,这些变化的字节分别对应GOT[1]和GOT[2]的位置。其中, GOT[1]包括动态链接器在解析函数地址时使用的信息,而GOT[2]则是动态链接器ld-linux.so模块中的入口点。加载时,动态链接器将重定位GOT中的这些条目,使它们包含正确的地址。内存的变化如下图所示:
5.7-2 dl_init前后.got.plt节的变化
5.8 本章小结
本章主要介绍了本章主要介绍了链接的的概念以及链接的作用,主要是将将预编译好了的一个目标文件(hello.o)或若干目标文件外加链接库合并成为一个可执行目标文件(hello)。
在Ubuntu下将hello.o文件经过链接器(ld)生成了可执行目标文件hello。
分析了hello的ELF格式,并用readelf等列出了其各节的基本信息,包括起始位置、大小等信息。
用edb查看了hello的虚拟地址空间,发现各节的名称都与相应的一段虚拟地址相对应,同时查看了各节的起始位置与大小。
对可执行目标文件hello进行反汇编,得到了反汇编程序,并与hello.o的反汇编程序进行比较。发现相比于hello.o的反汇编程序来说,经过链接过程后,hello的反汇编程序代码量增加,插入了C标准库中的函数代码,指令都分配了虚拟地址,字符串常量的引用、函数调用以及跳转指令的地址都替换为了虚拟地址。
分析了链接的过程,包括符号解析以及重定位。
使用edb执行hello,说明了从加载hello到_start,到call main,以及程序终止的所有过程,并列出了其调用与跳转的各个子程序名以及程序地址。
分析了hello程序的动态链接项目,通过edb调试,分析了在dl_init前后,这些.got.plt节的的内容变化,是由动态链接的延迟绑定造成的。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器地内容、程序计数器、环境变量以及打开文件描述符的集合。
6.1.2 进程的作用
进程提供独立的逻辑控制流,好像我们的程序独占地使用处理器;也提供一个私有的地址空间,好像我们的程序独占地使用内存系统;使CPU被科学有效地划分成多个部分以并行地运行多个进程。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 壳Shell-bash的作用
Shell为用户提供命令行界面,使用户可以在这个界面中输入shell命令,然后shell执行一系列的读/求值步骤,读步骤读取用户的输入的命令行,求值步骤则解析命令行,并运行程序。完成后重复上述步骤,直到用户退出shell。从而完成用户与计算机的交互来操作计算机。
6.2.2 壳Shell-bash的处理流程
Shell打印一个命令行提示符,等待用户输入指令。在用户输入指令后,从终端读取该命令并进行解析,若该命令为shell的内置命令,则立即执行该命令;若不是内置命令,是一个可执行目标文件,则shell创建会通过fork创建一个子进程,并通过execve加载并运行该可执行目标文件,用waitpid命令等待执行结束后对其进行回收,从内核中将其删除;若将该文件转到后台运行,则shell返回到循环的顶部,等待下一个命令行。完成上述过程后,shell重复上述过程,直到用户退出shell。
6.3 Hello的fork进程创建过程
Fork过程:
父进程通过调用fork()函数可以创建一个新的运行的子进程。
调用fork()函数后,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程虚拟地址空间相同的但独立的一份副本,包括代码、数据段、堆、共享库以及用户栈,子进程获得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用fork()函数时,子进程可以读写父进程中打开的任何文件。
子进程有不同于父进程的PID,fork()被调用一次,返回两次。子进程返回0,父进程返回子进程的PID。
Hello进行fork进程创建,需要在shell中输入如下命令:
./hello 7203610702 袁艺铭 2
这时在shell中就会调用fork()函数为hello创建一个shell的子进程。
6.4 Hello的execve过程
Execev过程:在shell给hello进行fork()函数创建子进程之后,会调用execve函数,在进程的上下文中加载并运行hello,调用_start创建新的且被初始化为0的栈等,随后将控制给主函数main,并传入参数列表和环境变量列表。
只有当出现错误时,exceve才会返回到调用程序,否则,exceve调用一次且不返回。在exceve加载完毕可执行目标文件hello后,会调用启动代码,启动代码设置栈,将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序,由此将控制转移给新程序的主函数。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
当开始运行hello时,内存为hello分配时间片,如一个系统运行着多个进程,那么处理器的一个物理控制流就被分成了多个逻辑控制流,逻辑流的执行是交错的,它们轮流使用处理器,会存在并发执行的现象。其中,一个进程执行它的控制流的一部分的每一时间段叫做时间片。然后在用户态下执行并保存上下文。
如果在此期间内发生了异常或系统中断,则内核会休眠该进程,并在核心态中进行上下文切换,控制将交付给其他进程。
当hello 执行到 sleep时,hello 会休眠,再次上下文切换,控制交付给其他进程,一段时间后再次上下文切换,恢复hello在休眠前的上下文信息,控制权回到 hello 继续执行。
hello在循环后,程序调用 getchar() , hello 从用户态进入核心态,并再次上下文切换,控制交付给其他进程。最终,内核从其他进程回到 hello 进程,在return后进程结束。
6.6 Hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1 正常运行
正常执行时,hello每隔两秒打印一行“Hello 7203610702 袁艺铭”,进入循环,共打印八次。打印完毕后,调用getchar()函数,等待用户输入回车后程序终止。Shell回收hello子进程,继续等待用户输入指令。
图6.6-1 hello正常运行
6.6.2 不停乱按
当在程序执行过程中随机乱按时,按下的字符串会直接显示,但不会干扰程序的运行,由于在乱按过程中没有输入回车,所以在最后一行hello的字符串打印完毕后,需要敲一个回车才能退出程序。
图6.6-2 在hello执行过程中不停乱按
6.6.3 按回车
在hello执行过程中敲回车时,会首先再打印的过程中显示换行,一个回车显示一排换行。在打印完毕最后一行字符串后,由于输入的回车依然存在于stdin中,所以在调用getchar()函数时,会读取stdin中的回车,因此无需再敲回车键,便能终止程序。
图6.6-3 在hello执行过程中按回车
程序终止后,发现shell中出现五个空行,这是因为在程序的执行过程中,敲了六下回车键,因此都留在stdin中,getchar()只接收了其中的第一个回车,由于在程序终止后没有清空stdin,剩余的回车保留在其中。当shell继续运行时,遇到回车便开始处理,但单独的回车相当于一个空行,被shell忽略,读入但不执行任何操作,因此留下了五个空行。
6.6.4 按Ctrl-z
在程序执行过程中按Ctrl-z,产生中断异常,发送信号SIGSTP,这时hello的父进程shell会接收到信号SIGSTP并运行信号处理程序。
最终的结果是hello被挂起,并打印相关信息。
图6.6-4 在hello执行过程中按Ctrl-z
- 输入ps
Ctrl-z之后,在shell命令行中输入ps,打印出各进程的pid,其中包括被挂起的hello。
图6.6-5 Ctrl-z后执行ps
- 输入jobs
Ctrl-z之后,在shell命令行中输入jobs,打印出被挂起的hello的jid及标识。
图6.6-6 Ctrl-z后执行jobs
- 输入pstree -p
Ctrl-z之后,在shell命令行中输入pstree -p,查看进程树之间的关系,同时输出对应的进程pid。在进程树中找到hello(2476),发现hello的父进程是zsh(2233),从祖先进程到hello的树为:systemed(1)→systemed(1477) →gnome-terminal-(2226) →zsh(2233)→hello(2476)。
图6.6-7 Ctrl-z后执行pstree以及hello的位置
- 输入fg
Ctrl-z之后,在shell命令行中输入fg,被挂起在后台的hello进程被重新调到前台执行,打印出剩余部分,按回车后终止程序。
图6.6-8 Ctrl-z后执行fg
- 输入kill
Ctrl-z之后,输入ps,得到hello的pid为8433,因此,在shell中输入kill -9 8433,可以发送信号SIGKILL给进程8433,该进程被杀死。
图6.6-9 Ctrl-z后执行kill
6.6.5 按Ctrl-c
运行hello时按Ctrl-C,会导致断异常,从而内核产生信号SIGINT,发送给hello的父进程,父进程收到它后,向子进程发生SIGKILL来强制终止子进程hello并回收它。这时再运行ps,可以发现并没有进程hello,可以说明他已经被终止并回收了。
图6.6-10 在hello执行过程中按Ctrl-c
6.7本章小结
本章主要介绍了进程的概念与作用。进程就是一个执行中程序的实例,CPU为每个进程提供独立的逻辑控制流,和一个私有的地址空间,从而将CPU科学有效地划分成多个部分并行地运行多个进程。
同时介绍了壳Shell-bash作用与处理流程。Shell为用户提供命令行界面,使用户可以在这个界面中输入shell命令,然后shell执行一系列的读/求值步骤,读步骤读取用户的输入的命令行,求值步骤则解析命令行,并运行程序。完成后重复上述步骤,直到用户退出shell。
明确了hello的fork进程创建过程与execve过程,通过调用fork()函数与execve()来实现。同时结合了进程上下文信息、进程时间片、用户态与核心态转换等,介绍了hello是如何在shell中作为一个子进程执行的。
分析了hello执行过程中按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等出现的异常、产生的信号,在Ctrl-z后运行ps、jobs、pstree、fg、kill等命令,查看这些指令对应的内容,进程的状态等,说明了异常与信号的处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.1.1 逻辑地址
逻辑地址为由程序产生的与段有关的偏移地址。逻辑地址分为两个部分,一个部分为段基址,另一个部分为段偏移量。在CPU保护模式下,需要经过寻址方式的计算和变换才可以得到内存中的有效地址。
在hello的反汇编代码中的地址即为逻辑地址,需要加上相应的段基址才能得到真正的地址。
7.1.2 线性地址
线性地址是地址空间中连续的整数,是逻辑地址到物理地址变换之间的中间层。在各段中,逻辑地址是段中的偏移地址,其偏移量加上基地址就是线性地址。
可执行目标文件hello反汇编代码中的偏移地址(逻辑地址)与基地址相加后,即得到了对应内容的线性地址。
7.1.3 虚拟地址
虚拟地址是指程序访问存储器所使用的逻辑地址。使用虚拟寻址时,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送至内存前先转换成适当的物理地址。在linux中,虚拟地址数值等于线性地址。
在查看可执行目标文件hello的elf格式时,程序头中的VirtAddr即为各节的虚拟地址。由于在linux中,虚拟地址数值等于线性地址,所以在hello反汇编代码中的地址加上对应段基地址的值即为虚拟地址。
7.1.4 物理地址
计算机系统的主存被组织成一个由 M 个连续的字节大小的单元组成的数组,其中每一个字节都被给予一个唯一的物理地址。
在hello的运行过程中,hello内的虚拟地址经过地址翻译后得到的即为物理地址,并在机器中通过物理地址来访问数据。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由2部分组成:段选择符和段内偏移量。
段选择符的构成如下图所示:由一个16位长的字段组成;其中前13位是索引号,用来确定当前使用的段描述符在描述符表中的位置;后面3位表示一些硬件细节,包含TI与RPL:TI选择全局(GDT)或局部描述符表(LDT),RPL选择内核态与用户态。
图7.2-1 段选择符
根据段选择符,首先判断应该选择全局描述符表(GDT,TI=0)还是局部描述符表(LDT,TI=1);然后根据GDT与LDT所对应的寄存器,得到地址和大小,获得段描述符表;接着,查看段选择符的前13位,通过索引在段描述符表中找到对应的段描述符;从而可以得到Base字段,即开始位置的线性地址。
图7.2-2 获得线性地址
将开始位置的线性地址与段内偏移量相加,就能得到相应的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(虚拟地址)由虚拟页号VPN和虚拟页偏移VPO组成。
页表是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址。
由虚拟地址到物理地址的变换通过以下步骤进行:首先从得到hello进程的页目录地址,将这个地址存储在相应的寄存器;同时根据线性地址前10位,即面目录索引,在数组中找到与之对应的索引项,即一个页表的地址,其中存储页的地址,然后根据线性地址的中间10位,即页表索引,在页表中找到页的起始地址,最后将页的起始地址与线性地址中最后12位,即偏移量,将它们相加相加,就可以得到物理地址。
当引用内容时,首先内存管理单元从线性地址中抽取出虚拟页号,并且检查高速地址变址缓存,看它是否缓存于DRAM中,若命中,则再查找对应组中是否命中。若命中,将缓存的内容返回给内存管理单元。若不命中,即需要的内容不在物理内存中,则产生缺页中断,需要从虚拟内存所给出对应的磁盘的内容重新加载到物理内存中。
图7.3-1 页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
现代 CPU 都包含一张名为 TLB(Transfer Look-aside Table),叫做快表,或者高速地址变址缓存,以加速对于页表的访问。
图7.4-1 加入TLB的命中与不命中
TLB通常有高度的相联度。用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2^t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
一个多级页表的层次如下图所示:
图7.4-2 多级层次页表的地址翻译
若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。
经过四级页表支持下的VA到PA的变换,虽然所经历的步骤更多,但如果一级页表的一个PTE是空的,对应的二级页表就不会存在,因此可以节省大量未被使用的空间。
7.5 三级Cache支持下的物理内存访问
MMU将物理地址发给L1缓存,在得到物理地址PA后,缓存从物理地址中取出缓存偏移CO、缓存组索引CI以及缓存标记CT。通过CI组索引,每组8路,对8路的块分别进行CT匹配,若匹配成功且块的有效位为1,则检测到一个命中,根据CO取出数据,并返回给MMU,进而传递给CPU;如果匹配不成功,则为不命中,继续向低一级缓存查询,按照L1-L2-L3-主存的顺序,查找成功后,将相应的块将其放入当前cache中。若映射到的组内存在空闲块,则可以直接放置;否则产生冲突,通过使用LRU进行替换。
7.6 hello进程fork时的内存映射
当fork()函数被父进程调用时,内核创建一个子进程,为新的子进程创建各种数据结构,并分配给子进程一个唯一的pid(与父进程不同)。
为了给hello进程创建虚拟内存,fork()函数创建了当前进程的mm_struct、区域结构和页表的原样副本,并将两个进程的每个页面都标记为只读,将两个进程中的区域结构都标记为私有的写时复制。
当fork()在从新的进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同但相互独立,映射的也是同一个物理内存。当这两个进程中的任一个进行写操作时,写时复制机制就会创建新页面,在新的页面中进行写操作,并且原来的虚拟内存映射到创建的新页面上,因此每个进程都具有私有的地址空间。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行可执行目标文件hello,即用hello程序替代当前程序。加载并运行hello需要如下几个步骤:
删除已存在的用户区域,即:删除当前进程虚拟地址的用户部分中的已存在的区域结构。
映射私有区域,即:为新程序hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有、写时复制的。代码和数据区域被映射为hello文件中的.text和.data 区。bss 区域是二进制零的,映射到匿名文件,其大小包含在hello 中,但没有内容。栈和堆区域也是二进制零的,初始长度为零。
映射共享区域,即:如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC) ,即:设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。如下例所示:CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位为0推断出VP3未被缓存,从而触发一个缺页异常。缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,即存放在PP3中的VP4。若VP4已经被修改了,那么内核就会将它复制回磁盘,否则直接修改。无论哪种情况,内核都会修改VP4的页表条目,替换为VP3的页表条目。
图7.8-1 VM缺页(之前)
接下来,内存从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。此时,VP3已经缓存在主存中,可以正常处理。经过缺页处理的页表状态如下图所示:
图7.8-2 VM缺页(之后)
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
7.9.1 动态内存管理的基本方法
当C程序运行时需要额外虚拟内存时,用动态内存分配器是一种更加方便、有更好可移植性的方法。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向更高的地址延申。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将内存视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块保留供应用程序使用,空闲块可用来分配。
分配器有两种基本风格:显式分配器与隐式分配器。其中显式分配器要求应用显式地释放任何已分配的块,如C语言中调用malloc函数来分配一个块,然后调用free函数来释放一个块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,就释放这个块。因此,隐式分配器也叫垃圾分配器。
在hello中,printf函数会调用malloc函数。malloc函数返回一个指针,指向大小至少为size字节的内存块:
图7.9-1 malloc函数
当程序不再需要malloc分配的区域时,需要通过free函数进行释放:
图7.9-2 free函数
以上就是动态内存管理的方法。
7.9.2 动态内存管理的策略
程序使用动态内存分配最重要的原因是:有时,当我们运行程序时,才会直到某些数据结构的大小,这样便需要使用到动态内存分配。
例如,要求一个C语言程序读一个ASCII码整数的链表,每一行一个整数,从stdin到一个C数组。输入是由整数n和接下来要读和存储到数组中的n个整数组成的。最简单的方法就是静态地定义这个数组,最大数组大小MAXN固定。
然而,如果这个程序使用者想要读取一个比MAXN大的文件,唯一的办法就是修改程序中MAXN的值,对大型软件产品而言不是一个好方法。
一种更好的方法是在已知了n的值后,动态地分配这个数组。使用这种方法,数组大小的最大值就只由虚拟内存数量来限制了。
hello中的printf作为一个已经编译、汇编好了,等待链接的函数,修改固定参数也是不现实的。
首先,对与堆中的块的组织,可以选择隐式、显示、分离空闲链表等。当分配器查找空闲块时,又可以采用不同的放置策略,如首次适配(从头开始搜索链表)、下一次适配(从上一次找到的空闲块的剩余块除法)以及最佳适配等。在分割空闲块时,又可以采用将剩余块分割为新的空闲块的策略。在合并时,可以采用带边界标记的合并,就像在上一段基本方法中所描述,通过边界标记来判断当前块周围是否也同样是空闲块,以此来判断是否需要合并。
以上就是动态内存管理的策略。
7.10本章小结
本章主要介绍了hello的存储器地址空间。结合了hello,说明了逻辑地址、线性地址、虚拟地址、物理地址的概念,以及它们的区别与联系,互相转化的方法。
分析了段式管理是如何完成逻辑地址到线性地址(虚拟地址)的变换的,包括段选择符和段内偏移量的作用。
分析了页式管理是如何完成线性地址到物理地址的变换的。
分析了TLB与四级页表支持下的VA到PA的变换。高速地址变址缓存TLB有加速对于页表访问的功能。以四级页表为例,介绍了多级页表的层次、工作流程以及节省空间的优点。
介绍了三级Cache支持下的物理内存访问的流程,包括在命中情况下的与未命中情况下的。
分析了hello进程fork与execve时的内存映射,着重从写时复制机制介绍了创建子进程时的虚拟内存相同且独立与物理内存共享。
介绍了缺页故障与缺页中断的处理。以一个VM缺页为例,介绍了缺页中断的处理流程。
分析了动态存储分配管理。从动态内存管理的基本方法与动态内存管理的策略两个方面对动态内存管理进行介绍。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
在Linux中,所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这就是Unix I/O接口。
这使得所有的输入和输出都能以一种统一且一致的方式来执行。这便是Linux的IO设备管理方法。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口
Linux将设备映射为文件,所有输入输出都当做文件读写来执行。
这样由Linux内核引出的一个简单、低级的应用接口,称为Unix I/O。
Unix I/O接口使得所有的输入和输出都能以统一一致的方式来执行:
- 打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
- 每个进程开始时三个打开的文件
标准输入、标准输出和标准错误,描述符分别为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参数指定了新文件的访问权限位。
以下是flag位的掩码以及对应的文件打开方式:
掩码 | 描述 |
O_DONLY | 只读 |
O_WONLY | 只写 |
O_RDWR | 可读可写 |
O_CREAT | 如果文件不存在,就创建它的一个截断的(空)文件 |
O_TRUNC | 如果文件已经存在,就截断它 |
O_APPEND | 在每次写操作前,设置文件位置到文件的结尾处 |
以下是mode位的掩码以及对应的描述:
掩码 | 描述 |
S_IRUSR | 使用者(拥有者)能够读这个文件 |
S_IWUSR | 使用者(拥有者)能够写这个文件 |
S_IXUSR | 使用者(拥有者)能够执行这个文件 |
S_IRGRP | 拥有者所在组的成员能够读这个文件 |
S_IWGRP | 拥有者所在组的成员能够写这个文件 |
S_IXGRP | 拥有者所在组的成员能够执行这个文件 |
S_IROTH | 其他人(任何人)能够读这个文件 |
S_IWOTH | 其他人(任何人)能够写这个文件 |
S_IXOTH | 其他人(任何人)能够执行这个文件 |
返回:若成功则为新文件描述符,若出错为-1。
- 关闭文件
int close(int fd);
返回:若成功则为0, 若出错则为-1。
- 读文件
ssize_t read(int fd, void *buf, size_t n);
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。
- 写文件
ssize_t write(int fd, const void *buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。
返回:若成功则为写的字节数,若出错则为-1。
8.3 printf的实现分析
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
以下是printf函数的函数体,首先观察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;
}
可以发现printf的第一个参数是const char*类型的形参fmt,而后面的参数用…代替,是一种可变形参的写法,因为对于printf来说传入的参数个数不确定。那么为了正确进行打印,在之后便需要设法得知传入参数的个数。
va_list arg = (va_list)((char*)(&fmt) + 4);
这句说明它是一个字符指针,其中的(char*)(&fmt) + 4) 表示的是...中的第一个参数。这是因为C语言中,参数压栈的方向是从右往左的。也就是说,当调用printf函数的适合,先是最右边的参数入栈。
接下来是i = vsprintf(buf, fmt, arg);
首先查看一下vsprintf(buf, fmt, arg)的函数体:
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);
}
经过分析发现,vsprintf返回的是要打印的字符串的长度。
后面一句:write(buf, i);
这句是写操作,就是传入buf与参数数量i,将buf中的i个元素写到终端。那么来观察一下write的实现:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
就是给几个寄存器传递了几个参数,然后int INT_VECTOR_SYS_CALL结束。可以找到INT_VECTOR_SYS_CALL的实现:
init_idt_desc(INT_VECTOR_SYS_CALL,DA_386IGate, sys_call, PRIVILEGE_USER);
一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
再来看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
在这里,sys_call实现了显示格式化了的字符串,也就是ASCII到字模库到显示vram的信息。从而实现了字符串的显示。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
#define getchar() getc(stdin)
以上是getchar()的实现,可以发现getchar()是由一个宏实现的。
getchar()有一个int型的返回值。当程序调用getchar时,程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中,直到用户按回车为止(回车字符也放在缓冲区中)。
当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。
getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错则返回-1,且将用户输入的字符回显到屏幕。
如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。
也就是说,后续的getchar调用不会等待用户按键,而是直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
这可以看做一个异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar函数调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了hello的IO管理,从Linux的IO设备管理方法入手,介绍了Linux中,所有的I/O设备都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
分析了Unix I/O接口及其打开文件、关闭文件、读写文件时的功能以及相应的函数使用。
分析了printf()函数是如何在显示屏上打印字符串的,主要运用到了Unix I/O接口写的思想。
分析了getchar()函数是如何从缓冲区读入键盘的输入的,主要运用到了Unix I/O接口读的思想,还包括异步异常——键盘中断的处理。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello程序的生命周期是从一个高级C语言程序开始的。
为了在系统上运行hello.c程序,hello.c首先经过预处理器(cpp)得到修改了的源程序hello.i。这时的hello.i代码量比hello.c大大增加,其中注释部分被删除了,引用的头文件被插入到了源代码中,除头文件与注释以外的源代码保持不变。
接着,编译器(cc1)将hello.i翻译为汇编程序hello.s。在这个过程中,高级语言被翻译为机器逻辑下的汇编语言,编译器可能会根据编译选项的不同对程序进行一些优化。
然后hello.s经过汇编器(as)翻译成机器语言指令,把这些指令打包成可重定位目标程序hello.o。hello.o是一个二进制文件,可以查看它的ELF格式和相应的反汇编代码。查看ELF格式可以发现,hello.o是由不同的节组成的,每个节都有相应的大小和功能。
接下来,经过链接器,将调用的标准C库中的函数(如printf等)对应的预编译好了的目标文件以某种方式合并到hello.o文件中,得到可执行目标程序hello。hello也是一个二进制文件,可以查看它的ELF格式与反汇编代码。相比于hello.o,hello的代码量大大增加,这是由于一些C标准库中的函数(如printf)等被插入了hello中。同时,每一行代码、字符串常量等,都会被分配相应的虚拟地址,其中的数字以二进制表示,并以十六进制的形式显示出来。Hello是可以直接运行的文件。
当我们运行hello时,在shell中利用fork()函数创建子进程,为子进程分配一个与父进程相同但独立的虚拟内存空间,实行写时复制机制,再用execve()加载hello程序,这时,hello就由程序(program)变成了一个进程(process)。
映射虚拟内存,程序开始时载入物理内存,进入CPU处理。CPU为执行文件hello分配时间片,进行取指、译码、执行等流水线操作。
内存管理器和CPU在执行过程中通过L1、L2、L3三级缓存和TLB多级页表,经过一系列地址变换,从逻辑地址,到线性地址,最终得到物理,利用物理地址,从物理内存中取得数据。
最后通过I\O系统根据代码指令进行输出。在程序运行结束后,父进程会对其进行回收,内核把它从系统中清除。这就是hello所经历的过程。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名 | 作用 |
hello.c | 原C语言文件 |
hello.i | 预处理产生文件 |
hello.s | 编译产生文件 |
hello.o | 汇编产生文件 |
hello.out | 链接产生可执行文件 |
hello_o_elf.txt | 查看hello.o的elf格式对应的文本文件 |
hello_o_asm.txt | 查看hello.o的反汇编对应的文本文件 |
hello_elf.txt | 查看hello的elf格式对应的文本文件 |
hello_asm.txt | 查看hello的反汇编对应的文本文件 |
参考文献
[1] Randal E.Bryant等.深入理解计算机系统(原书第3版)[M]. 北京:机械工业出版社,2016.7:2.