计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机类
学 号
班 级
学 生
指 导 教 师
计算机科学与技术学院
2021年5月
本文通过分析hello程序的一生,借助gcc、objdump、edb等工具,详细分析了程序从最初的源代码经过预处理、编译、汇编、链接等操作转变成可执行程序的全过程,并分析了运行hello程序时系统的进程管理、存储管理以及系统级I/O。
关键词:hello程序;编译;进程;系统级I/O
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
- 使用文本编辑器编写hello.c源文件
- 使用GCC编译器将hello.c翻译成可执行目标文件hello,其中经过预处理、编译、汇编、链接等操作。
- Shell通过fork一个新的子进程,使用execve将hello加载到内存中并执行,
- 子进程通过execve系统调用启动加载器。加载器创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它会调用应用程序的main函数。
- 程序运行结束后,shell回收进程,释放虚拟地址空间,删除有关的上下文。
1.2 环境与工具
- 硬件环境:AMD Ryzen 7 4800U; 1.8GHz; 16G RAM;512GHD Disk
- 软件环境:Windows 11;Vmware pro 16;Ubuntu 20.04
- 开发与调试工具:vscode;vim; Gdb; Objdump
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
- hello.c 源文件
- hello.i 经过预处理的文本文件 分析预处理过程
- hello.s 经过编译后的汇编代码文件 分析编译过程
- hello.o 经过汇编后的可重定位目标文件 分析汇编过程
- hello 经过链接的可执行目标程序文件 分析链接过程
- hello.elf hello.o的elf格式文件 查看可重定向文件的ELF结构
- hello.obj hello.o的反汇编文件 通过反汇编查看hello.o的内容
- hello2.obj hello的反汇编文件 通过反汇编查看hello的内容
- hello.objelf hello的elf格式文件 查看重定向文件的ELF结构
1.4 本章小结
第1章简单介绍了hello程序的一生,同时还介绍了完成大作业时的软硬件环境以及实验过程所用到的开发工具和调试工具,最后展示了实验过程生成的必要的中间文件,它们在实验过程中起到了至关重要的作用。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
- 概念:指的是在对一个C语言源文件进行正式编译之前所做的工作
- 作用:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。
- 将源文件中”#include”格式所包含的文件插入到程序文本中。
- 进行宏替换,用实际值替换用”#define”定义的宏
- 根据条件编译指令后面的条件决定需要编译的代码内容
2.2在Ubuntu下预处理的命令
预处理命令:gcc -E -o hello.i hello.c
2.3 Hello的预处理结果解析
1. 下面我们使用wc -l hello.i 命令查询预处理完文本文件的行数,共3062行
考虑到行数过多,因此选择使用vscode查看hello.i的内容。
2. 通过对比源文件(左图)和预处理完的文本文件(右图),我们可以发现源文件中的#include在预处理过程将对应的头文件插入到源文件中,同时预处理完的文本文件也不包含源文件中的注释部分,说明在预处理过程中还将注释部分进行了删除。
3. 此外,当我们打开stdio.h头文件时,发现文件中定义有很多的条件编译(左图)和宏(右图),但是这些都不在预处理完后的文本文件中。
说明头文件在插入时,预处理器还进行了宏展开的操作,可以看出,预处理的程序仍是C语言的源程序,可以正常的阅读,只是将源程序做了一些简单的处理。
2.4 本章小结
第2章介绍了预处理的概念和作用,并在Ubuntu上使用预处理命令由hello.c源文件生成hello.i文件,之后再利用vscode查看hello.i的实际内容,我们可以清晰看到经过预处理之后的结果,从实际操作中了解了预处理的过程。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
1. 概念:对预处理完之后的文件进行一系列的词法分析、语法分析、语义分析以及优化后生成对应的汇编语言。
2. 作用:将高级语言(C语言)翻译成低层次的汇编语言,同时在翻译过程中发现有语法错误则给出错误提示信息。
3.2 在Ubuntu下编译的命令
编译命令: gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1汇编初始部分
节名称 作用
.file 声明源文件
.text 代码节
.section.rodata 只读数据段
.globl 声明全局变量
.type 声明一个符号是函数类型还是数据类型
.size 声明大小
.string 声明一个字符串
.align 声明对指令或者数据的存放地址进行对齐的方式
3.3.2 数据
(1)字符串
源程序总共有两个字符串(用双引号围起来的字符串),如下图所示
在经过编译后,这两个字符串存放在只读数据段中,观察hello.s文件,可以看到这两个常量被.LC0和.LC1标识
这两个字符串之后作为参数传递给printf函数
(2)局部变量
main函数声明了一个局部变量i,编译器进行编译的时候将局部变量i放入堆栈中。i被放置在栈中-4(%rbp)的位置
(3)参数argc
参数argc是main函数第一个参数,是通过寄存器%edi传递的,通过mov指令传送到-20(%rbp)处。
(4)数组char* argv[]
argv是的main函数的第二个参数,保存一个字符串数组的首地址,通过寄存器%rsi进行传递,通过mov指令传送到-32(%rbp)处。
(5)常数0,1,8等
均是以立即数的形式存在在汇编代码中,即 $ + 数字
- 常数0
2. 常数1
3 .常数8
编译器在处理的时候,考虑到是i<8,故编译成和7进行比较
3.3.3 全局函数
hello.c声明了一个函数int main(int argc,char *argv[]),且此函数是一个全局函数,如图所示
3.3.4 赋值操作
hello.c中赋值操作在for循环的初始化语句中,即i = 0,如图所示
i = 0,通过一条传送指令mov 来实现,其中-4(%rbp)中保存的为i的值
3.3.5 算术操作
hello.c中算术操作在for循环的控制条件语句中,即i ++,通过一条二元操作指令add来实现
3.3.6 关系操作
hello.c 有两个关系操作
- if语句中的判断语句中,即argc!=4
在汇编代码中通过cmp指令来实现,其中-20(%rbp)存放的是argc。比较之后 设置条件码,之后的je指令则根据条件码决定是否跳转
2. for循环中的判断条件语句,即i < 8,这里也是使用cmp指令来完成
3.3.7 数组操作
hello.c 中主要是对字符串数组进行操作,利用下标索引获取其值
其中-32(%rbp)存放的是数组argv的首地址,下面阐述argv[1]的汇编代码操作
- 首先先使用mov指令传送argv的地址值到%rax中
- 其次使用add指令,这样%rax中存放的是数组第二个元素的首地址,也就是&argv[1]
- 之后使用mov指令和内存引用,使得%rax中存放的是argv[1]的值
3.3.8 控制转移
hello.c 中使用if 和 for循环两种控制转移结构,通过cmp设置条件码来控制程序的跳转。
3.3.9 函数操作
hello.c中涉及的函数操作有以下几个:main,printf,exit,sleep,atoi和getchar函数。
1. main函数的参数是argc和*argv
2. printf函数的参数是字符串
3. exit函数的参数是1
4.sleep函数的参数是atoi(argv[3])。
5.atoi函数的参数是argv[3]
ISA定义了过程调用的约定规则,调用函数通过规定使用的寄存器传递给被调用函数需要的参数,再通过call来跳转到被调用的函数开头的地址,跳到被调用函数处执行代码,之后将函数的返回值会存储在%rax寄存器中,通过ret指令返回到调用函数。
3.3.10 类型转换
1. hello.c使用函数atoi(argv[3])将字符串类型转换为整型。
2. int、float、double、short、char之间可以通过强制类型转换或者隐式类型转换进行相互转换。
3.4 本章小结
本章主要介绍了编译器处理c语言源程序的基本过程,分别从c语言的数据,赋值语句,类型转换,算术操作,逻辑/位操作,关系操作,控制转移与函数操作这几点进行分析,经过这些步骤,编译器将hello程序从高级的C语言变成了低阶的汇编语言。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
- 概念:汇编是指从 .s 到 .o ,即编译后的文件到生成机器语言二进制程序的过程,汇编器(as)将.s汇编文件翻译成机器语言并将这些指令打包成可重定目标程序的格式存放在.o目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
2. 作用:将汇编语言翻译成机器语言,使其在链接后能够被机器识别并执行。
4.2 在Ubuntu下汇编的命令
命令 : gcc -c -o hello.o hello.s
4.3 可重定位目标elf格式
1. 典型的ELF可重定位目标文件
2. 使用readelf命令生成hello.o文件的elf格式文件hello.elf
3. 之后我们在vscode中打开该文件进行分析
4.3.1 ELF头
1. ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)
2. 分析ELF头可知:
该文件为REL(可重定位目标文件);机器类型为AMD X86-64;节头部表的文件偏移为0;字节顺序为小端序;节头大小为64字节;节头数量为14。
4.3.2节头
- 节头部表 : 记录各节名称、类型、地址、偏移量、大小、是否链接、读写权限等信息。
- 下面分析.rodata节,其大小为33个字节;虚拟地址为0;因为这个是可重定位目标文件;权限为A,即分配内存;相对于文件头的偏移量为0xd8字节。
4.3.3重定位节
- 重定位节保存的是.text节中需要被修正的信息(任何调用外部函数或者引用全局变量的指令都需要被修正),调用外部函数的指令和引用全局变量的指令需要重定位,调用局部函数的指令不需要重定位。Hello程序中需要被重定位的有printf、puts、exit、sleep、atoi、getchar和.rodata中的.L0和.L1。
2. .rela.eh_frame节是.eh_frame节的重定位信息。
3. 其中,重定位类型(type)常见有2种:
- R_X86_64_32:重定位绝对引用。重定位时使用一个32位的绝对地址的引用,通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改;
- R_X86_64_PC32:重定位PC相对引用。重定位时使用一个32位PC相对地址的引用。一个PC相对地址就是据程序计数器的当前运行值的偏移量。
4.3.4符号表
- 符号表 : .symtab,一个符号表,它存放在程序中定义和引用的函数和全局变量的信息,每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
2. 符号表每个条目都对应一个符号的语义,具体包括:
(1)名称name:以整型变量存放在符号表中,是字符串表.strtab中的字节偏移,指向符号的以null结尾的字符串名字。
(2)地址value:距定义目标的节的起始位置的偏移,对于可执行目标文件来说是一个绝对的运行时地址。
(3)类型type:表明符号的类型,通常要么是数据,要么是函数。
(4)范围binding:该字段表明符号是本地的还是全局的。
(5)分配目标section:该字段是一个到节头部表的索引,表明对应符号被分配至目标文件的某个节。
有三个特殊的伪节,它们在节头部表中是没有条目的:ABS代表不该被重定位的符号;UNDEF代表未定义的符号,即本模块中引用的外部符号;COMMON表示还未被分配位置的未初始化的数据目标。
(6) 目标大小size
3. 以符号main为例子,他是位于.text节中偏移量为0的146个字节函数,且符号为全局的。
4.4 Hello.o的结果解析
1. 首先使用命令objdump -d -r hello.o > hello.obj ,将hello.o的反汇编结果重定向到文本文件hello.obj中
2. 之后使用vscode查看内容并与hello.s进行比较,看它们之间的差异
(1) 分支转移
hello.s 中
而在hello.obj中
跳转指令je和加载有效地址指令不再是段名称,而是确定的地址
(2) 函数调用
hello.s 中
而在hello.obj 中
函数调用call指令后面不再是函数的具体名称,而是一条重定位条目的信息, call后面的为偏移量
(3) 立即数
hello.s中的立即数在hello.obj中全部为对应的十六进制格式
4.5 本章小结
第四章对应的主要是hello.s汇编到hello.o的过程。我们利用readelf命令查看了hello.o的可重定位目标文件的格式,同时使用objdump反汇编hello.o,并将经过反汇编获得的hello.obj与hello.s进行比较,分析和阐述了从汇编语言进一步翻译成为机器语言的汇编过程。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
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.3 可执行目标文件hello的格式
首先获取hello的ELF格式,使用命令
readelf -a hello > hello_objelf
5.3.1 ELF头
1. ELF文件内容
2. hello.o的ELF分析
(1) 以7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00的十六进制序列作为ELF头的开头。这个序列描述了生成该文件的系统的字的大小为8字节和字节顺序为小端序。
(2) ELF头的大小为64字节;目标文件的类型为EXEC(可执行文件);机器类型为AMD X86-64;程序头条目数量为12、大小为56字节;节头条目数量为27、大小为64字节。
5.3.2 节头部表
1. 节头部表内容,如下图
2. 节头部表分析
(1) hello的节头部表增加了若干条目,共有26个条目,而hello.o的节头部表中只有13个节的信息。
(2) 所有节被分配了运行时的地址。而hello.o的节头部表中所有节的Address为全0。
5.3.3 程序头表
1. 程序头表
可执行文件或共享目标文件的程序头表是一个结构数组。每种结构都描述了系统准备程序执行所需的段或其他信息。
2. 程序头表内容
5.3.3 重定位节
1. 重定位节内容
2. 重定位节分析
这个节列举出了函数中详细的重定位信息
5.3.4 符号表
1. 符号表内容
2. 符号表分析
相较于可重定位目标文件的表项数目,可执行目标文件的符号表表项数目明显要多。可执行目标文件中加入了与调试、加载、动态链接相关的节,使得表示节的符号数增多,同时由于链接器对可重定位目标文件中的符号进行了进一步解析,加入了若干系统调用。
5.3.5 动态符号表
1. 动态符号表内容
2. 动态符号表分析
动态符号表 (.dynsym) 用来保存与动态链接相关的导入导出符号,不包括模块内部的符号。
5.4 hello的虚拟地址空间
使用edb加载hello,根据5.3.2的头部表
- .init 节,起始地址是0x401000,结束地址为0x40101b
2. .text节,起始地址为0x4010f0,结束地址为0x401235
5.5 链接的重定位过程分析
- 使用命令 objdump -d -r hello > hello2.obj 获得hello的反汇编文件
2.hello.o 和hello的区别
(1)虚拟地址不同
hello的虚拟地址从0x401000开始
而hello.o的虚拟地址从0开始
(2)call跳转的地址不同
hello的反汇编中call地址经过重定位已经计算出来了。
hello.o的反汇编中call地址为0,不过相对地址是一样的。
(3)代码量不同
hello经过链接后有很多函数、数据,反汇编代码有很多比如<_init>、<.plt>、<puts@plt>等节的代码,而hello.o的反汇编代码仅仅只有main函数的代码
5.5.1 hello重定位计算
1. 重定位过程:
(1)重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
(2)重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
(3)重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。代码的重定位条目放在.rela.txt
2. 重定位地址计算公式为:
(1) PC相对引用:
refaddr = ADDR(s) + r.offset
*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)
(2) 绝对引用
*refptr = (unsigned) (ADDR(r.symbol) + r.addend )
- 举例说明算法
其中,算法运行时,链接器为每个节(用ADDR(s)表示)和每个符号都选择了运行时地址(用ADDR(r.symbol))表示。这里ADDR(main)为<main>地址0x401125,ADDR(puts)为0x401090。而通过上图我们知道offest为21,type为R_X86_64_RLT32,addend为-0x4。
因此refaddr = 0x401104。
*refptr = 0xf46。最后结果也确实是这个。
5.6 hello的执行流程
1. 打开shell,键入命令edb --run ./hello 120L021422 李佳勇 1,执行hello。
2. 程序的初始地址在0x7f8621fd1000处,表明hello使用的动态链接库ld-2.33.so的入口点_dl_start位于此地址。
(1)之后程序跳转到_dl_init,地址为0x7f751dac2e80,进行初始化后。
(2)调到hello程序入口点_start。
(3)接着会通过call指令跳到_libc_start_main处,由该函数调用main函数。
(4)在_libc_start_main内部会调用_cxa_atexit函数,用来设置程序结束时需要调用的函数表。
(5)跳出_cxa_atexit之后,调用hello中的_lib_csu_init函数用于初始化。
(6)上述准备工作完成后,再调用hello!init函数
(7)最后调用main函数,开始hello的执行,main中会调用_printf,_exit,_sleep,_等函数完成程序功能。
综上,hello执行过程中各个子程序名如下表所示:
函数名 运行时地址
ld-2.33.so!_dl_start 0x7f8621fd1000
ld-2.33.so!_dl_init 0x7f751dac2e80
hello!_start 0x4010f0
libc-2.33.so!_libc_start_main 0x7f8621dfd490
libc-2.33.so!_cxa_atexit 0x7f8621e19670
Hello!_libc_csu_init 0x4011c0
hello!init 0x401000
hello_main 0x401125
hello!puts@plt 0x401030
hello!printf@plt 0x401040
hello!getchar@plt 0x401050
hello!atoi@plt 0x401060
hello!exit@plt 0x401070
hello!sleep@plt 0x401080
5.7 Hello的动态链接分析
- 共享链接库代码是一个动态的目标模块,在程序开始运行或者调用程序加载时,可以自动加载该代码到任意的一个内存地址,并和一个在目标模块内存中的应用程序链接了起来,这个过程就是对动态链接的重定位过程。
而在一个动态的共享链接库中仍然存在着一个可以调用程序加载而动态链接无需重定位的位置无关代码,编译器在程序中的函数开始运行时是不能自动预测各个函数的开始运行时间和地址的,这就可能需要系统添加重定位的记录,交给一个动态共享链接器或者采用它来进行重定位的动态共享链接,动态共享链接器本身就是负责执行对动态链接的重定位过程,这样做就有效地防止了程序运行时自动修改或者调用目标模块的位置无关代码段。
2. 动态的链接器在正常工作时链接器采取了延迟绑定的链接器策略,由于静态的编译器本身无法准确预测变量和函数的绝对运行时地址,动态的链接器需要等待编译器在程序开始加载时再对编译器进行延迟解析,这样的延迟绑定策略称之为动态延迟绑定。GOT链接器叫做全局变量过程偏移链接表,在PLT和GOT中分别存放着链接器的目标变量和函数的运行时地址。一个动态的链接器通过静态的过程偏移链接表PLT+GOT链接器实现了函数的一个动态过程链接,这样一来,它就已经包含了正确的绝对运行时地址。
3. 分析过程 :
(1) 首先通过节头表找到GOT起始位置0x404000:
(2) GOT表在调用dl_init前的情况
(3)GOT表在调用dl_init后的情况
GOT表位置在调用dl_init之前0x404008后的16个字节均为0,调用完之后字节数据发生了改变,和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址, GOT[2]是动态链接器ld-linux.so模块中的入口点。
5.8 本章小结
第五章分析了可重定位目标文件hello.o的链接过程,通过objdump反汇编hello的汇编代码,分析了链接详细工作和过程,其中详细分析了重定位过程,最后使用edb调试hello程序,查看hello的执行流程和动态链接过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
1. 概念:进程是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。
2. 作用:
进程提供给应用程序关键抽象:
(1)一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。(2)一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
1.shell的定义
shell是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。
2.shell的作用
实际上shell是一个命令解释器,它解释由用户输入的命令并且把它们送到
内核。不仅如此,shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的shell程序与其他应用程序具有同样的效果。
3.shell的处理流程
shell首先检查命令是否是内部命令,若不是再检查是否是一个可执行程序(这里的应用程序可以是Linux本身的程序,如ls和rm,也可以是下载的软件vim等)。然后shell在搜索路径里寻找这些可执行程序。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,则fork一个子进程来运行这个命令。
6.3 Hello的fork进程创建过程
当我们在shell中输入命令./hello 120L021422 李佳勇 1,shell 会对输入的命令进行解析,发现不是内置命令,shell之后在当前目录下寻找hello可执行文件,找到了则fork一个新的子进程,然后通过execve函数加载hello程序并在这个子进程中运行。
6.4 Hello的execve过程
在新fork的子进程中,execve函数加载并运行hello,且带参数列表argv和环境变量envp。同时execve加载并运行需要以下步骤:
1.删除已存在的用户区域
2.创建新的区域结构
(1)私有的、写时复制
(2)代码和初始化数据映射到.text和.data区(目标文件提供)
(3).bss和栈堆映射到匿名文件 ,栈堆的初始长度0
3.共享对象由动态链接映射到本进程共享区域
4.设置PC,指向代码区域的入口点。Linux根据需要换入代码和数据页面
6.5 Hello的进程执行
- 上下文切换的机制 :
1)保存以前进程的上下文。
2)恢复新恢复进程被保存的上下文。
3)将控制传递给这个新恢复的进程,来完成上下文切换。
2. hello程序中的sleep和printf函数都可能引起上下文切换。
(1) sleep函数显式地请求让hello进程休眠,此时进程进入内核模式,并将该进程设置成阻塞态,之后内核选择执行上下文切换,切换到另一个进程,当到sleep函数设置的时间,内核将控制返回给调用sleep的进程。
(2) hello调用printf函数时系统调用read函数,使用系统调用函数read之后陷入内核模式,内核中的陷阱处理程序请求来自键盘缓冲区的输入,由于取数据需要一段较长的时间,因此内核执行上下文切换。当完成键盘缓冲区到内存的数据传输时,发出一个中断信号,内核将控制返回到hello进程。
3. 当分配给hello进程的时间片到时,内核认为hello进程运行了足够长的时间,也会发生上下文切换。
6.6 hello的异常与信号处理
在hello程序执行过程中可能会因为按下Ctrl-Z,Ctrl-C键而收到SIGINT、SIGTSTP等信号,在hello程序结束时则会向shell进程产生SIGCHLD信号
6.6.1 SIGCHLD信号
这里主要时shell进程对SIGCHLD信号的处理,当hello向shell发送一个SIGCHLD,shell会调用自己的SIGCHLD处理程序来回收子进程,当shell停止,shell仅仅是将它的运行状态改为停止;否则,shell直接将其回收。
6.6.2 SIGINT和SIGSTP信号
当我们在hello程序的执行过程中在键盘上按下Ctrl+C/Z,会导致系统给shell发送SIGINT/SIGTSTP信号。而shell捕获到信号则会给前台进程组(hello程序)kill
SIGINT/SIGTSTP信号。由于我们的hello程序并没有信号处理程序,因此hello程序在收到信号时会终止/停止,同时给shell进程发送SIGCHLD信号。
- 按下Ctrl + Z 向hello进程发送SIGSTP信号,这时我们使用jobs命令查看hello进程已经说Stopped状态,进程树也能看到hello进程
- 使用fg命令让hello进程继续在前台执行,按下Ctrl + C 向进程发送SIGINT信号,此时使用ps命令可以看到hello进程已经终止被shell回收了。此时进程树也看不到hello了。
6.7本章小结
第六章先是简单介绍了进程和shell的概念和作用,之后详细介绍了在shell中运行hello可执行文件时fork、execve和执行的过程,最后介绍了hello进程在执行时对可能出现的异常和信号的处理过程。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1 逻辑地址
逻辑地址 : 在IA 32时期,逻辑地址由[段选择符: 段内偏移量]组成。在IA 64中,进程的地址不再分段,相当于段选择符代表的段基址以0开始,因此逻辑地址只由段内偏移一个量决定。
7.1.2 线性地址
线性地址 : 在IA 32中,线性地址由逻辑地址经过转换得到。IA 64中,由于不存在段,偏移量也就不需要转换才能得到线性地址了,因此逻辑地址实际上就是线性地址。
7.1.3 虚拟地址
虚拟地址 : 逻辑地址在转换成物理地址之前需要先转换成虚拟地址。不能用来直接访存,需要使用MMU翻译成物理地址。
7.1.4 物理地址
物理地址 : 一个计算机操作系统的物理主存被组织为一个由m个连续的字节相同大小的单元内存。物理地址就是内存中每个内存单元的编号。大小取决于内存中内存单元的数量。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理是应用在IA32架构上的管理模式。一组寄存器(CS,DS,SS等)保存着当前进程各段(如代码段、数据段、堆栈段)在描述符表中的索引,可以用来查询每段的逻辑地址。当获取了形如[aaaa:bbbb]的逻辑地址,可以通过简单的运算来取得线性地址(段基址*0x10H+段内偏移)。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址到物理地址的转换是通过分页完成的。首先CPU通过生成一个虚拟地址来访问主存,这个虚拟地址被送到内存之前首先转换为物理地址,CPU上的内存管理单元(MMU)利用主存中的查询表来动态翻译虚拟地址。
虚拟地址转换为物理地址时需要检查页表中虚拟页是否缓存在物理内存中,若不缓存在物理内存,则查询该虚拟页在磁盘的位置。页表就是页表条目的数组,页表条目由有效位和地址字段组成。这样就建立了页表到虚拟内存或物理内存的映射。虚拟地址包括一个p位的虚拟页面偏移VPO,n-p位的虚拟页号VPN.MMU利用VPN来选择适当的PTE。将页表条目中物理页号和虚拟地址中的VPO串联起来就得到相应的物理地址。
若触发缺页异常,则传递CPU中的控制到操作系统内核的缺页异常处理程序。该程序确定物理内存中的牺牲页将其替换到磁盘中,调入新的页面并更新PTE。回到原来的进程再次执行原指令。
7.4 TLB与四级页表支持下的VA到PA的变换
1. 在7.3的一级页表的地址翻译中,虚拟地址位数为48位,物理地址为52位。在四级页表的情况下,36位的VPN被划分为4个9位的片,每个片被用作一个到页表的偏移量。CR3寄存器包含L1页表的物理地址,VPN1提供一个到L1 PTE的偏移量,这个PTE包含L2页表的基地址,以此类推。
2. 查询流程:
(1) CPU 产生虚拟地址传送给至MMU,MMU 使用前 36 位 VPN向TLB匹配。若命中,则得到物理页号与虚拟页偏移(VPO)组合成物理地址。
(2) 如果 TLB没有命中,就要向页表中查询所需要的物理页号,CR3确定第一级页表的起始地址,最终在第四级页表中查询物理页号,与虚拟页偏移组合成物理地址,并且向TLB中添加条目。
(3) 若PTE不在物理内存中,则产生缺页故障。
7.5 三级Cache支持下的物理内存访问
1. 首先先利用虚拟地址的组索引位,找到对应的组。再使用行匹配将虚拟地址的标记和组中各行的标记位比较。如果高速缓存行有效且标记位匹配则命中。
2. 高速缓存命中后,使用块偏移找到需要字节的块内偏移位置,将其取出返回给CPU。
3. 若不命中,则需要到较低一层L2中寻找所请求的块,若是找到则将新的块替换掉上一层的一行。一般使用最近最少使用的替换(LRU)策略进行替换。
4. 若没找到还需要到L3,主存甚至磁盘中重复以上过程。
7.6 hello进程fork时的内存映射
当shell调用fork时
- 内核为新进程创建包括页表、区域结构、mm_struct等数据结构,并将新进程与父进程映射到同一块虚拟内存,并标记这些页为只读,将两个进程中的每个区域结构都标记为私有的写时复制。
- 如果父子进程仅仅是读某一块内存,它们不需要花费额外时间来创建一块多余的副本
- 当其中某个进程需要写某区域时,这个写操作会触发一个保护故障,从而导致故障处理程序在物理内存中创建这个页面的一个新副本,并将页表条目指向这个新副本,恢复这个页面的可写权限。私有的写时复制节省了创建页面的时间,并充分利用了物理内存。
7.7 hello进程execve时的内存映射
execve加载并运行hello时
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
- 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
- 映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
- 设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
- 下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
1. 缺页(page fault)指虚拟内存中的DRAM缓存不命中。当CPU请求某个虚拟地址的数据而它恰好不在主存而在磁盘中时(通过检查有效位),就会引发缺页故障,调用内核中的缺页异常处理程序。
2. 处理程序首先判断虚拟地址是否合法,缺页处理程序搜索区域结构的链表,把虚拟地址和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。
3. 其次处理程序会判断进行的内存访问是否合法,即进程是否有读、写或者执行这个区域内页面的权限,如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。
- 否则则内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的,它会选择一个牺牲页,用所请求的页替换该牺牲页。
5. 如果该主存中的牺牲页还被修改过,在替换之前内核还需要将其复制回磁盘。牺牲页的选择因系统而异,常见的替换算法有LRU(Least Recently Used)算法,它选择一个最近最久未使用的页面作为牺牲页。
7.9动态存储分配管理
7.9.1 动态内存分配器的基本原理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,两种风格都要求应用显式地分配块,它们的不同之处在于由哪个实体来负责释放已分配的块。
(1)显式分配器:要求应用显式地释放任何已分配的块。例如,c标准库提供一种叫做malloc程序包的显式分配器。c程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。c++中的new和delete操作符与c中的malloc和free相当。
(2)隐式分配器:另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集,例如Lisp,ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
7.9.2 隐式空闲链表分配器原理
对于隐式空闲链表分配器,一个块是由一个字的头部、有效载荷、可能的一些额外的填充,以及在块的结尾处的一个字的脚部组成的。
其中头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。
这种结构称为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意:此时我们需要某种特殊标记的结束块,可以是一个设置了已分配位而大小为零的终止头部。
7.9.3 显式空间链表的基本原理
显式空闲链表结构将堆组织成一个双向空闲链表,在每个空闲块的主体中,都包含一个pred(前驱)和succ(后继)指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址
7.10本章小结
第七章介绍了软硬件协同下的计算机存储管理,虚拟地址的使用更加有效地管理内存,为每个进程提供了大的、一致的和私有的地址空间。我们介绍了三种不同的地址转换方式,详细了解了地址的翻译过程,同时结合hello进程的内存映射进一步深入虚拟内存,最后分析了缺页故障的处理以及动态存储分配管理,展示了如何在程序中使用和管理虚拟内存。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux把所有IO设备都模型化为字节序列,即文件。所有的输入和输出都被当作对相应文件的读和写来执行。Linux提供了Unix IO接口函数,使得所有的输入和输出以统一的方式进行。
8.2 简述Unix IO接口及其函数
8.2.1 open
1. 函数原型: int open(char *filename, int flags, mode_t mode);
2. open函数将filename转换成一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程如何访问这个文件。mode参数指定了新文件的访问权限位。
- 进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的
8.2.2 close
1. 函数原型 : int close(int fd);
2. 进程通过调用close函数关闭一个打开的文件,参数为open返回的文件描述符。其中关闭一个已经关闭的描述符会出错。
8.2.2 read和write
1. 函数原型 :
ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, void *buf, size_t n);
- read函数从描述符为fd的文件复制最多n个字节到内存位置buf。函数返回-1说明遇到错误,返回0表示EOF。否则,返回实际传送的字节数量。
- write函数从内存位置buf处至多复制n个字节到文件描述符为fd的文件中,若函数返回-1说明遇到错误,否则返回实际写入的字符数量。
- 进程通过调用read和write函数来执行输入输出。
8.3 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实现分析
va_list的类型为char*,通过typedef定义char* 的别名,fmt指向格式串,当在32位系统中,(&fmt)+4 为指向printf格式串右边的第一个参数,因此arg指printf的第一个参数,随后在printf内部调用vsprintf生成显示信息存放到buf中,同时返回字节数,紧接着它调用write系统函数,将buf中的i个char型元素写入终端。
8.4 getchar的实现分析
1. getchar实现分析
用户通过键盘按下输入的每个字符实际上是一个中断,键盘中断处理子程序将接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
第八章简单介绍了Linux的I/O设备管理,同时介绍了几个Unix IO接口及其函数,open、close、read和write,最后对两个常用的C语言函数printf和getchar的实现进行了分析,较为深入地了解了linux的I/O设计思想。
(第8章1分)
结论
hello程序的一生起自程序员用文本编辑器一个一个字符敲出来,保存成源程序。hello源程序到最终的可执行程序经历预处理、编译、汇编、链接一系列过程。预处理器(cpp)删除源程序的注释、拓展宏同时将需要的文件插入形成hello.i文件。编译器(cc1)对hello.i文件进行词法分析、语法分析、语义分析、优化等步骤后将其从高级语言到低级的x86-64汇编代码。再在汇编器(as)的作用下产生可重定位目标文件hello.o。最后链接器(ld)将所有需要的可重定位目标程序进行合并,得到最终的可执行目标文件hello,可以加载到内存中,由系统执行。
之后我们在shell终端上运行我们的hello程序,shell首先fork一个新的子进程,,并调用execve函数在新的子进程的上下文中加载并运行hello程序,使得hello进程得以在机器上执行。同时MMU结合TLB快表和Cache将hello进程的虚拟地址翻译成物理地址,为hello进程的地址请求保驾护航。在此期间软硬件协同的异常处理机制会对hello进程运行时产生或接受到的异常进行处理,Unix I/O接口及提供的系统函数为hello进程的文件访问提供了必要的服务。最后,hello程序运行结束,shell将其回收。hello程序的一生就此谢幕!
尽管hello程序很简单,却需要底层硬件和操作系统等软件协同,以满足hello程序的运行需求。通过hello程序让我意识到,尽管我们写程序可以不必考虑底层软硬件具体的工作,高级语言、集成开发环境给我们提供的抽象也确实如此。不过为了写出性能更加的程序,我们也需要了解底层机制,我想这也是《深入理解计算机系统》这门课给我们带来的收获吧!
(结论0分,缺失 -1分,根据内容酌情加分)
附件
- hello.i 经过预处理的文本文件 分析预处理过程
- hello.s 经过编译后的汇编代码文件 分析编译过程
- hello.o 经过汇编后的可重定位目标文件 分析汇编过程
- hello 经过链接的可执行目标程序文件 分析链接过程
- hello.elf hello.o的elf格式文件 查看可重定向文件的ELF结构
- hello.obj hello.o的反汇编文件 通过反汇编查看hello.o的内容
- hello2.obj hello的反汇编文件 通过反汇编查看hello的内容
- hello.objelf hello的elf格式文件 查看重定向文件的ELF结构
(附件0分,缺失 -1分)
参考文献
- 深入理解计算机系统
- 百度百科 . getchar. getchar(计算机语言函数)_百度百科
- 宏伟技术. C语言睡眠函数sleep()的原理. 2022-01-22. https://www.zhihu.com/ question/512859952
- 虚拟地址空间. 小林coding. 2021-06-27. https://www.zhihu.com/ question/290504400
(参考文献0分,缺失 -1分)