计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L021718
班 级 2003005
学 生 李骏成
指 导 教 师 吴锐
计算机科学与技术学院
2022年5月
本文细致地讲述了hello.c是如何一步步生成hello可执行文件,以及shell指令控制可执行文件运行的全过程。在这篇文章中发掘了IDE中一键编译运行背后的故事:预处理、编译、汇编、链接,从计算机系统底层解释了存储、内存分配、I/O过程,还诠释了子进程的轮回:创建与回收。以hello的一生为例,让人们了解在计算机系统中,平凡眼前事背后的默默无闻的细节,帮助读者梳理漫游计算机系统。
关键词:编译;汇编;链接;进程;异常与信号;虚拟内存
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 35 -
6.2.2 Shell-bash的处理流程... - 35 -
6.3 Hello的fork进程创建过程... - 36 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 43 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 45 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 46 -
7.5 三级Cache支持下的物理内存访问... - 47 -
7.6 hello进程fork时的内存映射... - 48 -
7.7 hello进程execve时的内存映射... - 48 -
7.9.2 带边界标签的隐式空闲链表分配器... - 50 -
第1章 概述
1.1 Hello简介
1.Hello的P2P过程:P2P具体是指From Program to Process,指的是从高级语言源程序通过预处理、编译、汇编、链接这些阶段,生成可执行的目标文件。
①预处理:预处理器(Preprocessor)处理以#开始的预编译指令,如宏定义(#define)、头文件引用(#include)、条件编译(#ifdef)等,具体是指将高级语言源程序的.c文件变成ASCII编码的高级语言源程序的.i文件。
②编译:使用编译器(Compiler)将C语言,翻译成为汇编代码,将高级语言源程序的.i文件变为汇编语言源程序的.s文件。
③汇编:使用汇编器(Assembler)将汇编代码翻译成二进制机器语言,将汇编语言源程序的.s文件转化为机器语言的.o文件(可重定位的目标文件)。
④链接:使用链接器(Linker)将汇编器生成的目标文件外加库链接为一个可执行文件。
2.Hello的020过程:020具体指的是From Zero-0 to Zero-0,指的是一开始没有Hello相关的进程,shell通过fork创建了一个子进程,在子进程中通过execve运行Hello程序,映射虚拟内存,将Hello程序相关内容存入到物理内存中。在Hello运行结束后,子进程终止,创建这个子进程的父进程回收子进程,删除子进程相关的内容,将与子进程有关的全部清空。所以对于Hello程序来说,运行前是没有相关进程的,运行后子进程被回收,也没有相关进程,所以是从0到0,即From Zero-0 to Zero-0。
1.2 环境与工具
1.2.1 硬件环境
i5-8265U X64 CPU; 1.80GHz; 8G RAM; 512GHD Disk
1.2.2 软件环境
Windows 10 64位; VMware Workstation Pro 16.2.0; Ubuntu 20.04.0
1.2.3 开发与调试工具
vim/gedit+gcc; gdb; edb; objdump; readelf; Visual Studio Code; wxHexEditor; CodeBlocks;
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
1.4 本章小结
本章是整篇论文的概述,首先介绍了Hello的P2P和020过程,描述了Hello程序编译和运行时的具体细节,还说明了编写本论文、分析和调试程序使用的软硬件环境,以及开发与调试工具,最后列举出了为了编写本论文,生成的中间结果文件的名字、文件的作用等。
第2章 预处理
2.1 预处理的概念与作用
预处理具体的过程是,将高级语言源程序的.c文件转化为ASCII编码的高级语言源程序.i文件,得到的.i文件用于后续步骤。
2.1.1 预处理的概念
预处理的概念是预处理器(preprocessor)在源代码编译之前对其进行一些文本性质的操作,操作的对象为原始代码中以字符#开头的命令,包括#include的头文件、#define的宏定义,#if、#ifdef、#endif等条件编译,得到的结果再由编译器核心进一步编译。
2.1.2 预处理的作用
预处理的作用是将程序中宏定义的变量名替换为对应的值、通过文件包含将多个源文件连接成一个源文件(有助于后续编译)、进行条件编译即只允许后续编译过程中只编译源程序中满足条件的程序段。
预处理阶段主要作用和目的是让编译器在随后对文本进行编译的过程中,更加方便,因为访问库函数这类操作在预处理阶段已经完成,减少了编译器的工作。
2.2在Ubuntu下预处理的命令
预处理的命令为gcc -E hello.c -o hello.i
输入后如图2-1所示:
图2-1 预处理命令执行结果
2.3 Hello的预处理结果解析
在Hello.c程序中,能够被预处理器处理的内容有注释和三条对库文件的引用,图2-2是它们的具体内容。
图2-2 Hello程序中在预处理中被处理的部分
①对于注释的内容,在预处理过程中是直接删掉的,所以不会显示在.i文件中。
②对于“#include <stdio.h>”命令,预处理直接解析了对stdio.h库文件的调用路径,然后存储在.i文件中,还将程序中需要的stdio.h中的代码补充到.i文件中,预处理结果如下图2-3所示。
图2-3 “#include <stdio.h>”的预处理结果
③对于“#include <unistd.h>”命令,预处理直接解析了对unistd.h库文件的调用路径,然后存储在.i文件中,还将程序中需要的unitstd.h中的代码补充到.i文件中,预处理结果如下图2-4所示。
图2-4 “#include <unistd.h>”的预处理结果
④对于“#include <stdlib.h>”命令,预处理直接解析了对stdlib.h库文件的调用路径,然后存储在.i文件中,还将程序中需要的stdlib.h中的代码补充到.i文件中,预处理结果如下图2-5所示。
图2-5 “#include <stdlib.h>”的预处理结果
2.4 本章小结
本章介绍了预处理的概念和作用,并且说明了Ubuntu下的预处理命令,展示和解析了Hello.c文件使用预处理命令后的结果。
预处理是从高级语言源程序到可执行文件的第一步,它的作用主要是为后面的编译过程奠基,去除无用部分,将宏定义的变量直接替换成对应值,找到引用库文件的路径,将程序中涉及到的库中的代码补充到程序中,最后将初步处理完成的文本保存在hello.i中,方便以后的内核器件直接使用。
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译是指编译器将预处理后的ASCII编码的高级语言源程序文件(.i文件),通过编译程序生成汇编语言目标程序,即.s文件。此阶段编译器会完成对代码的语法和语义的分析,生成汇编代码,并将这个代码保存在hello.s文件中。
3.1.2 编译的作用
编译的基本作用是把高级语言源程序翻译成汇编语言目标程序,还应具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人-机联系等重要作用。
①语法检查:检查源程序是否合乎语法。如果不符合语法,编译程序要指出语法错误的部位、性质和有关信息。
②调试措施:检查源程序是否合乎设计者的意图。编译程序在目标程序运行时能输出程序动态执行情况的信息,这些信息有助于用户核实和验证源程序是否表达了算法要求。
③修改手段:为用户提供简便的修改源程序的手段。编译程序通常要提供批量修改手段(用于修改数量较大或临时不易修改的错误)和现场修改手段(用于运行时修改数量较少、临时易改的错误)。
④覆盖处理:主要是为处理程序长、数据量大的大型问题程序而设置的。
⑤目标程序优化:提高目标程序的质量,即占用的存储空间少,程序的运行时间短。依据优化目标的不同,编译程序可选择实现表达式优化、循环优化或程序全局优化。
⑥不同语言合用:其功能有助于用户利用多种程序设计语言编写应用程序或套用已有的不同语言书写的程序模块。最为常见的是高级语言和汇编语言的合用。
⑦人机联系:确定编译程序实现方案时达到精心设计的功能。目的是便于用户在编译和运行阶段及时了解内部工作情况,有效地监督、控制系统的运行。
3.2 在Ubuntu下编译的命令
编译的命令为gcc -S hello.i -o hello.s
输入后结果如图3-1所示:
图3-1 编译命令执行结果
3.3 Hello的编译结果解析
3.3.1 Hello.s头部内容
指令 | 含义 |
.file | C文件声明 |
.text | 代码段 |
.globl | 声明全局变量 |
.data | 已初始化的全局和静态C变量 |
.align | 声明对指令或者数据的存放地址进行对齐的方式 |
.type | 指明函数类型或对象类型 |
.string | 声明string型数据 |
.section .rodata | 只读数据段 |
图3-2 Hello.s头部段
通过Hello.s文件的第一行可以知道,这个汇编语言目标程序是从Hello.c文件转化过来的。从第四行的.align 8可以知道,变量在内存中存储是以8字节对齐的。
第五行的.LC0是一个标记,代表了下面这个字符串的地址,第六行的字符串代表的是程序中“用法: Hello 学号 姓名 秒数!\n”这句话,在汇编语言中,每个汉字是由大于等于3个字节的数字表示的,所以可以知道一个汉字在Hello.s中是用3个字节的数字和“\”符号表示的。
第七行的.LC1也是一个标记,代表了下面这个字符串的地址,第八行的字符串表示的是程序中“Hello %s %s\n”这句话。
第十行表示程序中的全局变量为main。第十一行表示程序中的函数有main。
Hello.s的头部段大概记录程序的一些内容。
3.3.2 数据
Hello.s中出现的数据类型包括整数、数组、字符串。
1.整数
1)int i
i是for循环中的变量,虽然在main函数一开始就定义了,但是在后面for循环中才赋值为0,然后每次循环加一的,通过图3-3和图3-4就可以分析出i是存储在内存中的,它的存储位置为%rbp-4。
图3-3 i的声明和赋值为0
图3-4 i的加一
2)int argc
argc是main函数的形参,虽然在参数传递的时候没有明确传递argc,但是它表示的是argv参数列表中元素的个数的,argc主要是在程序后方的条件判断中使用的,将argc与4进行比较,通过图3-5可以知道,argc是存储在内存中的,它的存储位置为%rbp-20。
图3-5 argc的表示
2.数组
char *argv[]
char *argv[]为字符串数组,在这个数组中每个单元存储的是一个char *类型的变量,char *指的是字符类型的指针,即为一个字符串的地址,所以这个数组的每个单元大小为8字节,数组的起始地址为argv。通过图3-6可见,argv的值为内存中地址为%rbp-32的内容,故内存中地址为-32(%rbp)+16的8字节的内容为argv[2],内存中地址为-32(%rbp)+8的8字节的内容为argv[1]。
图3-6 数组char *argv[]的表示
3.字符串
1)“用法: Hello 学号 姓名 秒数!\n”
这个字符串是作为printf函数的第一个参数的,它的存储地址是用相对寻址方式表示的,存放在只读数据段.rodata中,可以发现字符串被编码成utf-8格式,一个汉字在utf-8编码中占三个字节,一个\代表一个字节。这个字符串的具体表示见图3-7。
图3-7 “用法: Hello 学号 姓名 秒数!\n”具体表示
2)“Hello %s %s\n”
第二个printf传入的输出格式化参数,存放在只读数据段.rodata中。如图3-8所示。
图3-8 “Hello %s %s\n”具体表示
3.3.3 赋值
i=0
i是保存在栈中的局部变量,直接用mov语句对i进行赋值,因为i是4B即32b,所以用movl对i进行赋值。如图3-9所示。
图3-9 i=0赋值操作
3.3.4 类型转换
设计显式类型转换的是atoi(argv[3]),此处是进行一个从字符串到整数的类型转换。这种类型转换要求字符串中必须全是数字型的字符,只有这种情况才能转换成为一个整数。
在汇编语言中,argv[3]的地址为-32(%rbp)+24,argv[3]会作为参数传递进入函数atoi中,函数atoi的返回值存放在寄存器%eax中,作为函数sleep的参数进行传递。类型转换的具体过程如图3-10。
图3-10 类型转换atoi函数的表示
3.3.5 算数操作
1)for循环中i++
自增指令在汇编语言中被解释为被操作数加一,采用add指令,因为i是4B即32b,所以用addl对i进行增加,如图3-11所示。
图3-11 i++指令表示
2)leaq计算“Hello %s %s\n”字符串的地址
for循环内部需要对“Hello %s %s\n”字符串进行打印,使用了加载有效地址指令leaq计算字符串的地址%rip+.LC1并传递给%rdi,可以看出这个字符串使用的是相对寻址方式,一般全局变量和指令跳转时大多使用相对寻址方式。具体如图3-12所示。
图3-12 leaq计算“Hello %s %s\n”字符串的地址的表示
3.3.6 关系操作
总结进行关系操作的指令如下:
指令 | 基于 | 解释 |
CMP S1, S2 | S2-S1 | 比较设置条件码 |
TEST S1, S2 | S1&S2 | 测试设置条件码 |
SET** D | D=** | 按照**将条件码设置D |
J** | —— | 根据**与条件码进行跳转 |
程序中涉及的关系运算如下:
1)argc!=4
在Hello.s中表示判断argc是否等于4,将argc即-20(%rbp)减去4,设置条件码,然后通过条件码判断差值是否为0,再决定是否进行跳转。具体如图3-13。
图3-13 argc!=4的汇编表示
2)i < 8
在Hello.s中表示判断i是否小于8,将i的值即-4(%rbp)减去7,设置条件码,通过条件码判断i是否小于等于7,再决定之后如何跳转。具体表示见图3-14。
图3-14 i<8的汇编表示
3.3.7 控制转移
1)if(argc!=4)
在24行处,先计算argc即-20(%rbp)减去4,设置条件码,然后25行通过判断条件码决定如何进行跳转。如果argc等于4,则满足25行的条件,程序将会跳转到.L2处,如果argc不等于4,则继续运行,输出地址为.LC0+%rip处的字符串,结束程序。条件转移的具体表示如图3-15所示。
图3-15 if(argc!=4)的汇编表示
2)for(i=0; i<8; i++)
使用变量i循环8次。首先无条件跳转到位于循环体.L3之后的比较代码,使用cmpl进行比较,如果i<=7,则跳入.L4 for循环体执行,否则说明循环结束,顺序执行for之后的逻辑。如图3-16所示。
图3-16 for(i=0; i<8; i++)的汇编表示
3.3.8 数组操作
这个程序中出现的数组为字符串型数组char *argv[],每个单元为8个字节,每个单元中存放的是字符串的地址。其中argv为这个数组的地址,即为这个数组第一个元素的地址,argv为-32(%rbp),根据数组地址的计算方法可以得到argv[1]、argv[2]、argv[3]的地址。数组地址的计算方法为&(argv[a])=argv+a*8。
argv[1]和argv[2]的地址在Hello.s中具体的表示过程描述如下。
argv[1]: 数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上偏移量$16,再将该位置内容放在%rdx中,成为下一个函数的第一个参数。
argv[2]: 数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上偏移量$8,再将该位置内容放在%rdi中,成为下一个函数的第二个参数。
汇编代码过程如图3-17所示。
图3-17 argv[1]和argv[2]的数组操作
3.3.9 函数操作
Hello.c中涉及的函数操作有:
- main函数:
- 传递控制:
main函数因为被调用call才能执行(被系统启动函数__libc_start_main调用),call指令将下一条指令的地址压栈,然后跳转到main函数。
- 传递数据:
外部调用过程向main函数传递参数argc和argv,分别使用%edi和%rsi存储,函数正常出口为return 0,将%eax设置0返回。
- 分配和释放内存:
使用%rbp记录栈帧的底,函数分配栈帧空间在%rbp之上,程序结束时,调用leave指令,leave相当于
mov %rbp,%rsp
pop %rbp
恢复栈空间为调用之前的状态,然后ret返回,ret相当pop IP。
- printf函数:
- 传递数据:
第一次printf将%rdi设置为“用法: Hello 学号 姓名 秒数!\n”字符串的首地址。第二次printf设置%rdi为“Hello %s %s\n”的首地址,设置%rdx为argv[1],%rsi为argv[2]。
- 控制传递:
第一次printf因为只有一个字符串参数,所以call puts@PLT;第二次printf使用call printf@PLT。
- exit函数:
- 传递数据
将%edi设置为1。
- 控制传递
call exit@PLT。
- sleep函数:
- 传递数据
将%edi设置为argv[3]。
- 控制传递
call sleep@PLT。
- getchar函数:
- 控制传递
call gethcar@PLT
3.4 本章小结
本章主要介绍了有关编译的概念作用,然后使用编译指令生成了编译后的文件。对于生成的.s文件,分析了C语言的数据与操作在机器之中如何被处理翻译的,明确了汇编文件中的每一步都是有何意义,为下一步汇编打下了基础。
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编指的是汇编器(Assembler)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标文件,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,包含hello程序执行的机器指令。
4.1.2 汇编的作用
将在hello.s中保存的汇编代码翻译成可以供机器执行的二进制代码,使之在链接后能够被计算机直接执行,为之后的链接和运行奠定基础。
4.2 在Ubuntu下汇编的命令
汇编命令为gcc -c hello.s -o hello.o
输入后结果如图4-1所示:
图4-1 汇编命令执行结果
使用winHex打开hello.o,显示如图4-2所示:
图4-2 使用winHex打开Hello.o
4.3 可重定位目标elf格式
使用命令 readelf -a hello.o > hello_o_elf.txt,读取hello.o文件的ELF格式至hello_o_elf_txt中。
4.3.1 ELF头
ELF头以一个16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行、共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。如图4-3所示。
图4-3 Hello.o的ELF头
4.3.2 节头部表
节头部表包括节的全部信息,如图4-4所示,各个节的名称及内容如下:
节名称 | 包含内容 |
.text | 已编译程序的机器代码 |
.rela.text | 一个.text节中位置的列表,链接器链接其他文件时,需修改这些内容 |
.data | 已初始化的全局和静态C变量 |
.bss | 未初始化的全局和静态C变量和所有被初始化为0的全局或静态变量 |
.rodata | 只读数据段 |
.comment | 包含版本控制信息 |
.note.GNU-stack | 包含注释信息,有独立的格式 |
.symtab | 符号表,存放程序中定义和引用的函数和全局变量信息 |
.strtab | 字符串表,包括.symtab和.debug节中的符号表以及节头部中的节名字 |
.shstrtab | 包含节区名称 |
图4-4 Hello.o节头部表
4.3.3 重定位信息
重定位是将EFL文件中的未定义符号关联到有效值的处理过程。在Hello.o中,对printf,exit等函数的未定义的引用替换为该进程的虚拟地址空间中机器代码所在的地址。如图4-5所示。
图4-5 Hello.o的重定位信息
4.3.4 符号表
符号表(.symtab)是用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明。如图4-6所示。
图4-6 Hello.o的符号表
4.4 Hello.o的结果解析
使用 objdump -d -r hello.o > helloo.objdump获得反汇编代码。
对比hello.s中main函数与反汇编后main函数如图4-7所示。
图4-7 编译结果和反汇编结果对比左侧为Hello.s,右侧为Helloo.objdump
两者的内容上差别不大,具体有如下的几个区别:
4.4.1 分支转移
反汇编代码跳转指令的操作数使用的不是段名称如.L2,段名称只是在汇编语言中便于编写的助记符,在汇编成机器语言之后使用地是确定的地址。
4.4.2 函数调用
在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,在链接后再进一步确定。
4.4.3 字符串地址访问
在.s文件中,访问.rodata(printf中的字符串),使用段名称+%rip,在反汇编代码中0+%rip,因为.rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
4.5 本章小结
本章介绍了hello从hello.s到hello.o的汇编过程,通过查看hello.o的ELF格式和使用objdump得到反汇编代码与hello.s进行比较的方式,间接了解到从汇编语言映射到机器语言汇编器需要实现的转换。
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行与运行时(run time),也就是有应用程序来执行。
5.1.2 链接的作用
链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够按操作系统装入执行的统一整体。
通过链接可以实现将头文件中引用的函数并入到程序中,解析未定义的符号引用,将目标文件中的占位符替换为符号的地址。完成程序中各目标文件的地址空间的组织,这可能涉及重定位工作。
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所示:
图5-1 输入链接命令后的结果
5.3 可执行目标文件hello的格式
输入readelf -a hello > hello.elf命令,生成hello可执行文件的elf格式信息,存入文件hello.elf中。
5.3.1 ELF头
通过ELF头可以知道文件类型、机器类型、版本、ELF头大小等信息,能够看到文件类型变成了EXEC(可执行文件),还可以知道节头表的地址和节头表中的条目数量,我们可以看到可执行文件hello中的节头表中的项目比hello.o中节头表的项目更多,说明链接上了其他的代码,节变多了。ELF头具体信息可见图5-2。
图5-2 Hello的ELF头信息
5.3.2 节头表
图5-3中是节头表的内容,可以看到节头表中项目确实比hello.o中的多了。
图5-3 Hello可执行文件的节头表
可以看到与hello.o不同,在可执行文件中经过重定位每个节的地址不再是0,而是根据自身大小加上对齐规则计算的偏移量。比如.hash的地址,计算方式是.note.ABI-tag的地址0x400320加上.note.ABI-tag的大小0x20,得到0x400340,现在的地址已经满足了.hash要求的8字节对齐,得到最终地址0x400340。
5.3.3 程序头表(段头表)
在可执行文件中有新增了一个可重定位的目标文件没有部分,它就是程序头表,也叫做段头表,在段头表中记录了文件中一个个段的相关信息,包括位置、大小、内容等等。因为在可重定位的目标文件中不分段,所以它是没有段头表的,但是到了可执行文件中,会有不同的节组合成为段,所以会出现段头表(程序头表)。
Hello可执行文件的段头表详细信息如图5-4所示:
图5-4 hello可执行文件的段头表(程序头表)
5.3.4 符号表
观察Hello的符号表我们可以发现,在可执行文件中多出了.dynym节。这里面存放的是通过动态链接解析出的符号,这里我们解析出的符号是程序引用的头文件中的函数。
正常的符号表仍然是存在着的,依旧保持原来的结构,内容类似,主要是内容数量比hello.o中的数量更多了,这也是链接了更多程序的必然结果。
符号表中新增的.dynym节如图5-5所示:
图5-5 Hello可执行文件中的.dynym节
5.4 hello的虚拟地址空间
根据节头表的信息,我们可以知道ELF是从0x400000开始的,如下图5-6所示。
图5-6 ELF开始部分
.text节是从0x400550开始的,和ELF头中程序的入口一致,如下图5-7所示。
图5-7 .text节内容
.rodata节从0x402000开始,如下图5-8所示。
图5-8 .rodata节内容
.data段从0x404048开始,如下图5-9所示。
图5-9 .data节内容
还可以直接使用symbol viewer查看各个节的地址,通过与5.3.2的节头表进行对比可以看出,各个节都是能够对应上的。具体情况如图5-10。
图5-10 edb中symbol viewer与节头表
5.5 链接的重定位过程分析
执行命令:objdump -d -r hello > hello.objdump 得到hello的反汇编文件。
图5-11展示hello.o反汇编的结果,图5-12展示hello反汇编结果。
图5-11 hello.o反汇编的结果
图5-12 hello反汇编结果
经比较,hello.o反汇编结果与hello反汇编结果在以下几个方面存在不同:
5.5.1 链接过程
1.函数个数
使用ld命令链接时,指定动态链接器为64的/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o中主要定义了程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器在链接过程中将上述函数加入。
2.函数调用
链接器解析重定条目时需要对R_X86_64_PLT32进行重定位。
对于R_X86_64_PLT32,此时动态链接库中的函数已经加入到了PLT中,.text与.plt节相对距离已经确定,链接器计算相对距离,将对动态链接库中函数的调用值改为PLT中相应函数与下条指令的相对地址,指向对应函数。
3..rodata段引用
链接器解析重定条目时需要对R_X86_64_PC32进行重定位。
对于R_X86_64_PC32,需要对.rodata的重定位,即printf中的两个字符串,.rodata与.text节之间的相对距离确定,链接器直接修改call之后的值为目标地址与下一条指令的地址之差,指向相应的字符串。这里以计算第一条字符串相对地址为例说明计算相对地址的算法,图5-13与计算结果相吻合:
ADDR(r.symbol)=0x402008
refaddr = ADDR(s)+r.offset
=ADDR(main)+r.offset
=0x401125+0x1c
=0x401141
*refptr = (unsigned) (ADDR(r.symbol) + r.addend-refaddr)
=ADDR(str1)+r.addend-refaddr
=0x402008+(-0x4)-0x401141
=(unsigned) 0xec3
图5-13 第一处call <puts>处,第一个字符串地址
5.5.2 重定位过程
1.关联符号定义
链接器将代码中的每个符号引用和一个符号定义关联起来。此时,链接器知道输入目标模块中的代码节和数据节的确切大小。
2.合并输入模块
链接器将所有输入到hello中相同类型的节合并为同一类型的新的聚合节。
3.符号引用
链接器修改hello中的代码段和数据段中对每一个符号的引用,使其指向正确的运行地址。
5.6 hello的执行流程
使用edb执行hello,查看从加载hello到_start,到call main,以及程序终止的所有过程。下表列出其调用的程序名称与各个程序地址。
程序名称 | 程序地址 |
ld-2.27.so!_dl_start | 0x7fce:8cc38ea0 |
ld-2.27.so!_dl_init | 0x7fce:8cc47630 |
hello!_start | 0x400500 |
libc-2.27.so!__libc_start_main | 0x7fce:8c867ab0 |
-libc-2.27.so!__cxa_atexit | 0x7fce:8c889430 |
-libc-2.27.so!__libc_csu_init | 0x4005c0 |
hello!_init | 0x400488 |
libc-2.27.so!_setjmp | 0x7fce:8c884c10 |
hello!main | 0x400532 |
hello!puts@plt | 0x4004b0 |
hello!exit@plt | 0x4004e0 |
*hello!printf@plt | —— |
*hello!sleep@plt | —— |
*hello!getchar@plt | —— |
ld-2.27.so!_dl_runtime_resolve_xsave | 0x7fce:8cc4e680 |
-ld-2.27.so!_dl_fixup | 0x7fce:8cc46df0 |
–ld-2.27.so!_dl_lookup_symbol_x | 0x7fce:8cc420b0 |
libc-2.27.so!exit | 0x7fce:8c889128 |
5.7 Hello的动态链接分析
动态链接库中的函数在程序执行的时候才会确定地址,所以编译器无法确定其地址,在汇编代码中也无法像静态库的函数那样体现。
hello程序对动态链接库的引用,基于数据段与代码段相对距离不变这一个机理,因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量。
动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。如果一个目标模块调用定义在共享库中的任何函数,那么就有自己的GOT和PLT。
PLT是一个数组,其中每个条目是16字节代码。每个库函数都有自己的PLT条目,PLT[0]是一个特殊的条目,跳转到动态链接器中。从PLT[2]开始的条目调用用户代码调用的函数。
GOT也是一个数组,每个条目是8字节的地址,和PLT联合使用时,GOT[2]是动态链接在ld-linux.so模块的入口点,其余条目对应于被调用的函数,在运行时被解析。每个条目都有匹配的PLT条目。
通过5.3.2的节头表可以知道.got.plt节的地址为0x404000,所以可以在内存中找到对应的位置,图5-14为dl_init前的GOT.PLT表,图5-15为dl_init后的GOT.PLT表。
图5-14 dl_init前的GOT.PLT表
图5-15 dl_init后的GOT.PLT表
5.8 本章小结
本章分析了链接过程中对程序的处理。Linux系统使用可执行可链接格式,即ELF,具有.text,.rodata等节,并且通过特定的结构组织。
经过链接,ELF可重定位的目标文件变成可执行的目标文件,链接器会将静态库代码写入程序中,以及调用动态库等相关信息,将地址进行重定位,从而保证寻址的正确进行。静态库直接写入代码即可,而动态链接过程相对复杂一些,涉及共享库的寻址。链接后,程序便能够在作为进程通过虚拟内存机制直接运行。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,包括代码段、数据段、和堆栈区。代码段存储CPU执行的代码,数据段存储变量和进程执行期间使用的动态分配的内存,堆栈区存储活动过程调用的指令和本地变量。
6.1.2 进程的作用
- 每次用户向shell输入一个可执行目标文件的名字运行时,shell就会创建一个新的进程,然后在这个进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。
- 提供给应用进程两个关键抽象:
a)一个独立的逻辑控制流,好像程序可以独使用处理器。
b)一个私有的地址空间,好像程序独占整个内存系统。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用
shell作为UNIX的一个重要组成部分,是它的外壳.也是用户与UNIX系统的交互作用界面.Shell是一个命令解释程序.除此,它还是一个高级程序设计语言。用shell编写的程序称为shell过程。shell的一项主要功能是在交互方式下解释从命令行输入的命令。shell的另一项重要功能是制定用户环境,这通常在shell的初始化文件中完成。shell还能用作解释性的编程语言。
6.2.2 Shell-bash的处理流程
1)从终端读入输入的命令。
2)将输入字符串切分获得所有的参数
3)如果是内置命令则立即执行
4)否则调用相应的程序执行(可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,或者是自由软件)
5)shell 应该接受键盘输入信号,并对这些信号进行相应处理
6.3 Hello的fork进程创建过程
在shell中输入命令./hello 120L021718 李骏成 2,shell首先将这个指令分割存入字符串数组argv[]中,然后判断出第一个字符串不在内置命令的范围中,所以知道这个命令不是内置命令。
shell之后会使用fork()函数创建一个子进程,子进程完全复制父进程的所有内容,子进程与父进程的唯一区别就是PID有所不同。
fork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0.父进程与子进程是并发运行的独立进程。
图6-1是运行命令./hello 120L021718 李骏成 2的结果。
图6-1 运行命令./hello 120L021718 李骏成 2的结果
6.4 Hello的execve过程
为了运行hello这个可执行目标文件,只有子进程是不够的,还需要在子进程中调用hello这个程序,于是需要使用execve函数进行调用,execve这个函数具体的声明如下:
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp);
这个函数的第一个参数为需要调用的程序的文件名;第二个参数为参数列表,是一个字符串数组,这个参数列表的第一个字符串为第一个参数的文件名;第三个参数为环境变量列表,只有发生错误时execve才会返回到调用程序。所以,execve调用一次且从不返回。
进程的地址空间如图6-2所示。
图6-2 进程的地址空间
加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域
删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域
为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域
如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址中的共享区域。
4.设置程序计数器
设置当前进程上下文中的程序计数器,使之指向代码段的入口点。下一次调度这个进程时,它将从这个入口点开始执行。
6.5 Hello的进程执行
由于现代计算机几乎在所有时刻都有超过一个进程在运行,但进程总是给我们一种程序独占的使用处理器的假象,而为了让多个进程在并发运行的时候还能维持这一假象,我们需要用到上下文这一概念以及上下文切换这一操作。
上下文的概念: 上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
上下文切换: 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用的时可能会发生上下文切换,当调用sleep的时候内核也可也觉得执行上下文切换。上下文切换的过程如图6-3所示:
图6-3 上下文切换的过程
以hello为例,hello程序在调用了sleep程序后会陷入内核状态,内核可能会进行上下文切换。到程序运行到getchar的时候,内核也会进行上下文切换,让其他进程运行。除了这些,系统还会为hello程序分配时间片,即使没有执行到getchar或者sleep函数,只要hello时间片被用完,系统就会判断当前程序以及执行够久了,从而进行上下文切换,将处理器让给其他进程。
6.6 hello的异常与信号处理
hello执行过程中会出现的异常:
①中断:信号SIGTSTP,默认行为是 停止直到下一个SIGCONT
②终止:信号SIGINT,默认行为是 终止
1)图6-4展示了程序正常运行的结果。程序执行完后,进程被回收,再按输入任意字符+回车键或者直接按回车键退出程序。
图6-4 程序正常运行结果
2)图6-5展示了运行时乱按时的结果,乱按的输入并不会影响进程的执行,当按到回车键时,getchar会读入回车符,并且后面的字符串会当作shell的命令行输入。
图6-5 程序运行时乱按时的结果
3)图6-6展示了运行时按Ctrl+C。父进程收到SIGINT信号,终止前台hello进程,并且回收hello进程。
图6-6 运行时按Ctrl+C的结果
4)图6-7展示了运行时按Ctrl+Z后运行ps命令。按下Ctrl+Z后,父进程收到SIGTSTP信号,将hello进程挂起,ps命令列出当前系统中的进程(包括僵死进程)。运行jobs命令。jobs命令列出当前shell环境中已启动的任务状态。
图6-7 运行时按Ctrl+Z,然后输入ps、jobs命令的结果
5)图6-8展示了运行时按下Ctrl+Z后运行pstree命令。pstree命令是以树状图显示进程间的关系。
图6-8 程序运行时先按Ctrl+Z,然后输入pstree的结果
6)图6-9展示了运行时按下Ctrl+Z后,输入fg命令将进程调到前台并继续这个进程的运行的结果,程序将继续执行至回车后正常结束。
图6-9 程序运行按Ctrl+Z,然后输入fg命令的结果
7)图6-10展示了运行时按下Ctrl+Z后,输入kill -9 PID命令终止这个进程的结果。在按下Ctrl+Z后,这个进程只是被放到了后台并且停止了,在输入kill命令之后,这个进程将被终止、回收。kill命令的本意是杀死一个进程,但是现在已经演变成为发出信号的一个命令,此处我是选择了9号命令,发出SIGINT信号使某个进程或进程组终止。
图6-10 程序运行先按Ctrl+Z,然后输入kill -9 5374的结果
6.7本章小结
本章介绍了计算机中最重要的概念之一——进程。可以说现代计算机离不开进程的概念,只有充分了解进程的创建与异常流控制,才可能成为一个优秀的程序员。这章我们讲述了一个进程是怎么在计算机中被创建的,一个程序是怎么通过子进程被执行的,这是P2P中的最后一步process。
这一章还介绍了shell的作用和处理流程,执行hello时的fork和execve过程。分析了hello的进程执行和异常与信号处理过程。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1 逻辑地址
逻辑地址是指由程序产生的与段相关的偏移地址部分。逻辑地址由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离。即hello.o里相对偏移地址。
7.1.2 线性地址
线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
7.1.3 虚拟地址
虚拟地址是程序保护模式下,程序访问存储器所使用的逻辑地址称为虚拟地址,与实地址模式下的分段地址类似,虚拟地址也可以写为“段:偏移量”的形式,这里的段是指段选择器。就是hello里面的虚拟内存地址。
7.1.4 物理地址
计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每个字节都有一个唯一的物理地址。第一个字节的地址为0,写下来的字节地址为1,再下一个为2,以此类推。虚拟地址通过MMU翻译后得到物理地址。在hello中通过翻译得到的物理地址来得到我们需要的数据。
CPU通过地址总线的寻址,找到真实的物理内存对应地址。 CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段标识符:段内偏移量组成。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。如图7-1:
图7-1 段选择符
最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。
分段功能在实模式和保护模式下有所不同。
1.实模式
即不设防,说逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。
2.保护模式
线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。段寄存器无法放下32位段基址,所以它们被称作选择符,用于引用段描述符表中的表项来获得描述符。描述符表中的一个条目描述一个段,构造如图7-2所示:
图7-2 段描述符
对各个部分的功能做简要介绍:
Base:基地址,32位线性地址,指向段的开始。
Limit:段界限,指出这个段的大小。
DPL:描述符的特权级0(最高特权,内核模式)-3(最低特权,用户模式)。
所有段描述符被保存在两个表中:全局描述符表(GDT)和局部描述符表(LDT)。电脑中的每一个CPU(或一个处理核心)都含有一个叫做gdtr的寄存器,用于保存GDT的首个字节所在的线性内存地址。为了选出一个段,必须向段寄存器加载以上格式的段选择符。
在保护模式下,分段机制就可以描述为:通过解析段寄存器中的段选择符在段描述符表中根据Index选择目标描述符条目Segment Descriptor,从目标描述符中提取出目标段的基地址Base address,最后加上偏移量offset共同构成线性地址Linear Address。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址也就是虚拟地址,我们一般通过页表来获得虚拟地址到物理地址的映射。
页表是一个关于页表条目PTE的数组。页表条目由有效位和物理页号组成。
一个虚拟页只有如下三个状态:
未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何内存。
已缓存的:当前已缓存在物理内存中的已分配页。
未缓存的:未缓存在虚拟内存中的已分配页。
如图7-3:
图7-3 虚拟内存与物理内存中的页式管理
结合以上两点,我们就可以按下图的方法通过页表将虚拟页映射到物理页上。
图7-4 页表管理物理页与虚拟页
接下来我们来讨论地址的翻译,由于接下来要分析多级页表,因此在这里我只论述一级页表的情况。
首先我们将n为的虚拟地址拆分成p为的虚拟页面偏移VPO和n-p位的VPN。我们通过VPN找到页表,并通过页表来获得虚拟页号,将m-p位的物理页号和p位的虚拟页面偏移组合在一起(虚拟页面偏移等价于物理页面偏移,因为物理内存映射的是虚拟内存的一整页。)就得到了m位的物理地址。如图7-5:
图7-5 虚拟地址到物理地址的转换
7.4 TLB与四级页表支持下的VA到PA的变换
在Intel Core i7环境下研究VA到PA的地址翻译问题。前提如下:
虚拟地址空间48位,物理地址空间52位,页表大小4KB,4级页表。TLB 4路16组相联。CR3指向第一级页表的起始位置(上下文一部分)。
解析前提条件:由一个页表大小4KB,一个PTE条目8B,共512个条目,使用9位二进制索引,一共4个页表共使用36位二进制索引,所以VPN共36位,因为VA 48位,所以VPO 12位;因为TLB共16组,所以TLBI需4位,因为VPN 36位,所以TLBT 32位。
如图7-6所示 ,CPU产生虚拟地址VA,VA传送给MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)向TLB中匹配,如果命中,则得到PPN(40bit)与VPO(12bit)组合成PA(52bit)。
如果TLB中没有命中,MMU向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并且向TLB中添加条目。
如果查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
图7-6 Core i7四级页表下地址翻译情况
7.5 三级Cache支持下的物理内存访问
Cache的访问并不复杂,对Cache的访问需要把一个物理地址分为标记、组索引、块偏移三个部分。首先我们通过组索引来找到我们的地址在Cache中所对应的组号,再通过标记和Cache的有效位来判断我们的内容是否在Cache中。若命中则通过块偏移读取我们要的数据,若不命中则从下一级Cache中寻找(下一级Cache不一定真的是Cache,比如对L3来说,它的下一级Cache就是主存)。
先来讨论一级Cahce,Core i7CPU的L1 Cache大小为32kb,每组八路,每个块大小为64字节。通过计算可以得出这个Cahce一共有64组。而我们知道,i7CPU的物理地址是52位,因此我们可以分析出这个Cache对物理地址的划分如图7-7:
图7-7 Cache对物理地址的划分
通过MMU将虚拟地址转化成物理地址后,计算机就通过提取中的组索引在L1中搜索组,再通过标记位匹配。如果匹配成功且有效位是1,则将块偏移指向的块中的内容交还给CPU,否则未命中,需要从下一级Cache中在重复上述操作。当我们找到内容后需要将内容写回我们的L1中,如果L1中没有空闲块,即有效位为0的块则需要牺牲一块内容,我们通常采用LRU算法来进行这一过程。对L2、L3的访问也是这样,因此就不再赘述。
7.6 hello进程fork时的内存映射
当fork函数被shell调用时,内核为hello创建各种数据结构,并分配给它一个唯一的PID。为了给hello创建虚拟内存,fork创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello中返回时,hello现在的虚拟内存刚好和调用shell的虚拟内存相同。当这两个进程中的任何一个进行写操作时,写时复制机制会创建新页面。因此也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
假设我们运行execve(“hello”, NULL, NULL);
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效的替代了当前程序。加载并运行hello需要以下结构步骤:
1、删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存 在的区域结构。
2、映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结 构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3、映射共享区域,hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程 上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
7.8.1 缺页故障
缺页故障是一种常见的故障,当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出时就会发生故障。故障处理流程如图7-8所示。
图7-8 故障异常处理流程
7.8.2 缺页中断处理
缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。
7.9动态存储分配管理
Printf会调用malloc,下面介绍动态内存管理的基本方法与策略。
7.9.1 动态分配器
虽然可以使用低级的mmap和munmap函数来创建和删除虚拟内存区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器更方便,也有更好的可移植性。
动态内存分配器维护着一个进程的虚拟内存域,称为堆。堆每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器有两种基本风格。两种风格都要求应用显示地分配块。它们的不同之处在于由哪个实体负责释放已分配的块。
隐式分配器:另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集,例如Lisp,ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
图7-9 隐式分配链表堆块格式
显式分配器:要求应用显式地释放任何已分配的块。例如,c标准库提供一种叫做malloc程序包的显式分配器。c程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。c++中的new和delete操作符与c中的malloc和free相当。
图7-10 双向空闲链表堆块格式
7.9.2 带边界标签的隐式空闲链表分配器
带边界标签的隐式空闲链表与普通的空闲链表不同,一个块除了是由一个字的头部、有效载荷、可能的一些额外的填充组成外,还有一个与头部相同的脚部组成。头部和脚部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。
我们称这种结构称为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。在带边界标签的隐式空闲链表中,我们的脚部就标记了一个块的结束。
合并的时候分配器就可以通过检查脚部来检查前一块的状态和大小了。
图7-11 隐式空闲列表结构
7.9.3 显式空闲链表
将空闲块组织为某种形式的显示数据结构是一种更好的方法,因为根据定义,程序不需要一个空闲块的主体,所以实现空闲链表数据结构的指针可以存放在这些空闲块的主体里面。
显式空闲链表是将对组织成双向链表。在每个空闲块的主体中,都包含一个pred(前驱)和succ(后继)指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。
另一种方法是按照地址顺序来维护链表,其中链表中的每一个块的地址都小于它后一个块的地址,在这种情况下释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章讨论了存储器地址空间,虚拟地址、物理地址、线性地址、逻辑地址的概念,TLB与四级页表支持下的VA到PA的变换,三级Cache支持下的物理内存访问,hello进程fork时和execve时的内存映射,缺页故障与缺页中断处理和动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O设备管理方法。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O接口
根据8.1中描述的Unix I/O接口的概念,我们可以确定I/O接口需要有如下结构功能:
1)打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想 要访问一个 I/O 设备
2)Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。
3)改变当前文件的位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。
4)读写文件。一个读操作就是从文件复制n > 0个字符到内存,从当前文件位置k开始,然后k += n。对给定一个大小为m字节的文件,当k>=m时执行读操作会出发一个称为EOF的条件。
5)关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。
8.2.2 Unix I/O函数
1、打开文件函数:int open(char *filename, int flags, mode_t mode);
flag参数为写提供一些额外的指示,mode指定了访问权限。
2、关闭文件函数:int close(int fd);
fd是打开文件时的返回值。
3、读文件函数:ssize_t read(int fd, void *buf, size_t n);
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
4、写文件函数:ssize_t write(int fd, const void *buf, size_t n);
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
printf函数功能:接受字符串指针数组fmt,然后将匹配到的参数按照fmt格式输出。图8-1是printf的代码,printf内部调用了两个外部函数,一个是vsprintf,还有一个是write。
图8-1 printf的代码
在形参列表里有这么一个token:...,这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。printf函数代码中的: (char*)(&fmt) + 4) 表示的是...中的第一个参数。
下面我们来看看下一句:
i = vsprintf(buf, fmt, arg);
让我们来看看vsprintf(buf, fmt, arg)是什么函数,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);
}
vsprintf的作用是格式化。接受确定输出格式的格式字符串fnt。用格式字符串对个数变化的参数进行格式化,产生格式化输出,并返回要打印的字符串的长度。
write函数代码如图8-2所示:
图8-2 write函数代码
write函数中,先给寄存器传了几个参数,然后通过系统调用sys_call,sys_call函数代码如图8-3所示:
图8-3 sys_call函数代码
syscall函数将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
先来看getchar的源代码:
- int getchar(void)
- {
- static char buf[BUFSIZ];
- static char* bb=buf;
- static int n=0;
- if(n==0)
- {
- n=read(0,buf,BUFSIZ);
- bb=buf;
- }
- return (--n>=0)?(unsigned char)*bb++:EOF;
- }
可以看到getchar的底层实现是通过系统函数read实现的。getchar通过read函数从缓冲区中读入一行,并返回读入的第一个字符,若读入失败则返回EOF。read的具体实现如下:
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章简述了Linux的I/O设备管理机制,Unix I/O接口及函数,并简要分析了printf函数和getchar函数的实现。Unix I/O把一切输入输出都归结为对文件的操作,而且这一抽象是十分成功的,因为I/O的过程本质上就是一个对信息交换的过程,因此把所有与程序进行信息交换的主体,比如网络设备当作文件是完全没问题的。这种抽象不仅可以简化计算机的设计,还能更好的帮助我们理解学习系统级I/O。
一、对于hello所经历的历程,可以总结如下:
1)编写:由程序员通过键盘键入代码,形成高级语言源程序hello.c
2)预处理:由预处理器(Preprocessor)进行预处理,经过对hello.c中的宏定义、库引用、条件编译的处理,得到以ASCII码组成的hello.i文件,此时仍为高级语言源程序。
3)编译:由编译器(Compiler)通过编译程序进行编译,将hello.i中的高级语言程序转化成汇编语言,生成hello.s文件,同时可以分析出每一处的具体作用,为之后的汇编做好铺垫。
4)汇编:由汇编器(Assembler)通过汇编程序进行汇编,将hello.s中的汇编语言源程序转化为二进制机器语言,同时生成hello.o这个可重定位的目标文件。
5)链接:由链接器(Linker)通过静态链接或动态链接进行链接,将hello.o与其他可重定位的目标文件、动态链接库链接到一起,生成hello这个可执行的目标文件。
6)运行:通过在shell中输入./hello 120L021718 李骏成 2,可以运行hello。
7)创建子进程:shell判断输入的命令不是内置命令,所以通过fork函数创建一个子进程,这个子进程与父进程并发运行。
8)子进程中调用hello:在子进程中通过execve函数运行hello这个程序,execve函数将删除之前的内容,将这个子进程归hello程序使用。hello程序中main函数接收命令行中传递的参数,程序从main函数开始运行。
9)执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行相应的控制逻辑流。
10)访问内存:CPU发出请求从内存中加载信息,MMU通过虚拟地址计算出物理地址,通过页表判断物理内存中是否缓存了对应的页,如果没有缓存,则通过缺页故障从磁盘上将对应页缓存到内存中。
11)动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存,像屏幕输出内容。
12)信号:如果运行中键入Ctrl + C或Ctrl + Z,会发送SIGINT或SIGSTP信号,系统会调用shell的信号处理函数,停止或挂起前台进程组中的所有进程。
13)终止:当hello程序运行完之后,子进程也会终止,子进程会向父进程发送SIGCHLD信号,父进程接收信号后,启动信号处理函数,回收终止的子进程,删除内核中相关的数据结构。
二、对于计算机系统的设计和实现的感悟如下:
我觉得计算机系统的每一部分设计的思路都很清晰,尤其在程序的结构和执行这一部分,可以说是环环相扣,每一步都起到了承上启下的作用。然后在链接的部分,对于相对位置和偏移量的使用非常神奇。在存储器层次和虚拟内存的部分,计算机系统能将有限的物理构件充分利用、提高速度,这种利用物理构件实现抽象思想非常令人惊叹,在计算机系统处理异常和信号的部分,虽然这些信号的功能看似非常简单,但是经过排列组合却能创造出无数有趣的结果,不得不说计算机确实是人类伟大的智慧结晶。
附件
下表列出所有hello分析过程中的中间产物的文件名,和相应的内容。
文件名称 | 文件说明 |
hello.c | hello源文件 |
hello.i | 预处理后文本文件 |
hello.s | 编译得到的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 链接后可执行文件 |
hello.objdump | hello可执行文件反汇编代码 |
hello.elf | hello的elf文件 |
helloo.objdump | hello.o(链接前)的反汇编文件 |
hello_o_elf.txt | hello.o的ELF格式 |
参考文献
[1] Linux C 常用库函数手册Open Source Guides - Linux Foundation
[2] ELF文件格式解析ELF文件格式解析_mergerly的博客-CSDN博客_elf文件格式
[3] Linux下逻辑地址、线性地址、物理地址详细总结Linux下逻辑地址、线性地址、物理地址详细总结_FreeeLinux的博客-CSDN博客_linux 物理地址 线性地址 逻辑地址
[4] linux下的文件I/O编程Linux下的文件I/O编程 | 《Linux就该这么学》
[5] fork()创建子进程步骤、函数用法及常见考点(内附fork()过程图)fork()创建子进程步骤、函数用法及常见考点(内附fork()过程图)_小岳王子的博客-CSDN博客_fork子进程