计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号
班 级
学 生
指 导 教 师
计算机科学与技术学院
2021年5月
本篇文章以hello的一生为主线,通过对hello程序从hello.c经过预处理、编译、汇编、链接生成可执行文件、加载并执行的一系列的过程分析,进一步探讨了预处理、编译、汇编、链接、进程管理、存储管理和I/O管理的相关概念以及实现;通过对hello一生的探索,让我们对计算机系统有更深的了解。
关键词:计算机系统;Hello程序;预处理;编译;汇编;链接;进程;异常;存储;虚拟内存;I/O ;
目 录
第1章 概述
1.1 Hello简介
Hello 的 P2P,指的是 From Program to Process ,其具体的过程为:hello.c 文件依次经过cpp预处理、ccl编译、as 汇编、ld 链接最终成为可执行目标程序hello(Program)。在linux下打开shell,键入命令./hello后,shell 通过fork产生子进程,调用 execve函数在新的子进程中加载并运行 hello,hello 便从可执行程序(Program)变成为进程(Process)。
Hello的020,指的是hello.c文件“From 0 to 0”,初始时内存中并无hello文件的相关内容(From 0),通过在Shell下调用execve函数,系统会将hello文件载入内存,执行hello相关代码,当程序运行结束后,hello进程被回收,内存中与其相关的数据也被清除,这即为“From 0 to 0”。
1.2 环境与工具
硬件环境:
CPU:Intel(R) Core(TM) i5-9300H
内存:16.0GB
磁盘: 447GB SSD
软件环境:
Windows10 64位;
Oracle VM VirtualBox
Ubuntu-20.04.4;
1.3 中间结果
hello源代码 | |
hello.i | 预处理之后的文本文件 |
hello.s | hello的汇编代码 |
hello1.s | hello.o的反汇编代码 |
hello2.s | hello的反汇编代码 |
hello.o | hello的可重定位文件 |
hello | hello的可执行文件 |
1.4 本章小结
本章通过两个术语(P2P,020)简单介绍了 hello 的一生;此外本章还介绍了本次实验的环境与用到的工具,以及从 hello.c 到 hello 的过程中产生的诸多中间结果,为接下来的章节做铺垫。
第2章 预处理
2.1 预处理的概念与作用
预处理概念:预处理器cpp根据以字符#开头的命令(宏定义、条件编译等),修改原始的C程序,同时将引用的所有库展开合并成为一个完整的文本文件。其结果是得到另一个C程序,通常是以.i作为文件扩展名。下图给出了常见的c语言预处理指令。
图2.1 c语言预处理指令
预处理作用:
- 将源文件中以”include”格式包含的文件复制到编译的源文件中。如hello.c文件中的#include <stdio.h>。
- 用实际值替换用“#define”定义的字符串。
- 根据“#if”后面的条件决定需要编译的代码(图2.1)。
- 删除所有注释 "//" 和 "/**/";
- 添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号;
- 保留所有的#pragma编译器指令,因为编译器需要使用它们;
2.2在Ubuntu下预处理的命令
命令行:linux> gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
在Linux下打开hello.i文件,可以发现hello.i程序已经拓展为3060行,行数比起hello.c文件大幅增加。其中, hello.c中的main函数相关代码在hello.i程序中对应着3047行到3060行。
图2.5 hello.i部分展示
在hello.i文件前部分的是大段的头文件 stdio.h unistd.h stdlib.h 的插入。由给定的 C 语言实现版本决定 #include 命令所指定文件的搜索路径。同时,也由实现版本决定文件名是否区分大小写。对于命令中使用尖括号指定的文件(<文件名>),预处理器通常会在特定的系统路径下搜索,例如,在 Unix 系统中,会搜索路径 /usr/local/include 与 /usr/include。
对于命令中用双引号指定的文件("文件名"),预处理器通常首先在当前目录下寻找,也就是包含该程序其他源文件的目录。如果在当前目录下没有找到,那么预处理器也会搜索系统的 include 路径。文件名中可以包含路径。但如果文件名中包含了路径,则预处理器只会到该目录下寻找。
图2.6 将头文件内容插入程序文本
2.4 本章小结
图2.7 预处理
本章说明了C预处理的概念与作用。通过比较hello.c与hello.c经过预处理得到的hello.i,对预处理有了更加深入直观的理解。
第3章 编译
3.1 编译的概念与作用
概念:编译器将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。其以高级程序设计语言书写的源程序作为输入,而以汇编语言表示的目标程序作为输出。
作用:将预处理完的文件进行一系列的词法分析、语法分析、语义分析及优化后生成相应的汇编代码。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
命令行:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1 数据
1.常量 常量大多是以指令中立即数的形式出现在汇编代码hello.s中。如下图为if(argc!=4)中的4和exit(1)中的1。
图3.3 常量
- 全局和静态变量
在我们的hello.c程序中,并没有用到全局变量和静态变量,所以会发现在hello.s的最开始伪指令中没有.data和.bss,但printf要用到的字符串位于.rodata只读数据节。例如:printf("用法: Hello 学号 姓名 秒数!\n"),可以看到.LC0的.string正是我们要输出的字符串;
图3.4 全局和静态变量
图3.5字符串
- 局部变量 非静态局部变量在程序运行时一般由栈进行管理,可在寄存器中进行操作。下图为for(i=0;i<8;i++)对i进行初始化的汇编语言指令,程序将0存到地址为%rbp-4的存储空间并通过addl指令对其进行+1操作。
图3.6 局部变量
3.3.2赋值表达式 对局部变量的赋值在汇编代码中一般通过mov指令完成。具体使用哪条mov指令由数据的大小决定。下图为对i赋初值的操作。值得注意的是,未赋初值的变量,一般不会对其处理。
图3.6 赋值表达式
3.3.3关系表达式 关系表达式在汇编语言中一般使用cmp指令实现,该指令会更新条件码,为可能的跳转指令做准备。下图为argc!=4和i<8这两个关系表达式的实现。
图3.7 关系表达式
3.3.4算数操作 hello.c中的算数操作有:i++,汇编语言用一系列的算数运算指令实现算术操作,下图为i++的实现和其他的汇编语言算数操作指令。
图3.8 算术运算
3.3.5关系操作 hello.c中的关系操作有argc!=4和i<8,汇编语言一般使用cmp等指令实现。它们只设置条件码而不改变任何其他寄存器。Hello.c中关系操作的实现已在3.3.3中阐述。下面给出实现关系操作的诸多指令。
图3.9关系操作
3.3.6数组和指针操作
数组数据结构可以存储一个固定大小的相同类型元素的顺序集合数组是用来存储一系列数据,它往往被认为是一系列相同类型的变量。
hello.c中的数组是作为main函数的第二个参数char *argv[],数组的每个元素都是一个指向字符类型的指针。为了读取指针所指向的内容,汇编语言利用多种寻址模式(图3.10),允许不同形式的内存引用,在汇编状态下访问数组一般是通过基址加变址这种寻址模式实现的。在hello.c程序中,数组首地址为-32(%rbp),将其存在%rax中,又因为char*占据八个字节,所以通过%rax+8,%rax+16来分别访问数组的第二个元素和第三个元素(图3.11)
图3.10 寻址模式
图3.11 数组操作
3.3.7 控制转移
为了实现控制转移,在这一部分经常用到jump指令,其使用规则如图3.12所示:
图3.12 jump指令
(1)if语句实现 在hello.c中有if(argc!=4),其首先使用cmpl指令比较argc和4的大小,设置条件码,然后je根据条件码比较argc与4是否相等,并根据结果选择是否进行跳转:若相等,则跳转到.L2;若不相等,则继续运行紧接着的下一条指令。
图3.13 if的实现
- for循环的实现 在hello.c中有for(i=0;i<8;i++),其实现如图3.14所示
图3.14 for循环
首先是对i赋初值为0,然后跳转到循环判断表达式测试是否满足循环条件,如果是的话则跳转到循环表达式。
3.3.8函数调用
C语言过程调用机制的一个关键特性在于使用了栈数据结构提供的后进先出的内存管理原则。通用的栈帧结构如下:
图3.15 栈结构
函数P调用Q包括下面一个或多个机制:
传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。该过程使用call和ret指令实现。
传递数据。P必须能够向Q提供一个或多个参数, Q必须能够向P返回一个值。P可以通过寄存器向Q传递6个参数,分别存放在%rdi、%rsi、%rdx、%rcx、%r8、%r9中,超出6个的部分就要通过栈来传递。
分配和释放内存。在开始时, Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
下面我们几个分析hello.s中的函数调用。
1、printf("用法: Hello 学号 姓名 秒数!\n"),对应的汇编语言如下:
直接将唯一的参数.LC0传递到%edi中,然后调用puts函数;
2、atoi(argv[3]),对应的汇编语言如下:
我们发现它将argv[3]作为参数进行传递。
3.4 本章小结
本章介绍了编译的概念与作用,同时,本章以hello.s文件为例,介绍了编译器如何处理各个数据类型以及各类操作,并通过比对验证了大部分数据、操作在汇编代码中的实现。
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它包含的是函数main的指令编码。如果我们在文本编辑器中打开hello.o文件,将看到一堆乱码。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
命令行:gcc hello.s -c -o hello.o
图4.1 汇编过程
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
可重定位目标文件的ELF格式如下:
图4.2 可重定位目标文件的ELF格式
- ELF头,如下图所示:
图4.3 ELF头
ELF头以一个16字节的MAGIC序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小,目标文件的类型(可重定位、可执行或者是共享的)、机器类型(如x86-64)、节头部表的文件偏移、以及节头部表中条目的大小和数量。
- 节头部表 包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。
图4.4 节头部表
- symtab表 一个符号表,里面存放着程序定义和引用的全局变量的信息。name是符号名称,对于可冲定位目标模块,value是符号相对于目标节的起始位置偏移,对于可执行目标文件,该值是一个绝对运行的地址。size是目标的大小,type要么是数据要么是函数。Bind字段表明符号是本地的还是全局的。注意,符号表不包含局部变量的条目。
图4.5 .symtab
-
4.重定位信息 .rel.text与.rel.data 存放着代码的重定位条目。当链接器把这个目标文件和其他文件组合时,需要修改这些位置
图4.6 重定位信息
每一个重定位条目的数据结构如下:
它包括offset需要被修改的引用的节偏移、symbol被修改引用应该指向的符号、type告知链接器如何修改新的引用、以及偏移调整addend
在这里我们关心两种重定位类型中:R_X86_64_PC32和R_X86_64_32
-
CSAPP中也给出了重定位的计算方法:
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
objdump -d -r hello.o 得到的hello.o的反汇编结果如下图所示:
-
图4.7 反汇编结果
- 机器语言:机器语言是机器能直接识别的程序语言或指令代码,无需经过翻译,每一操作码在计算机内部都有相应的电路来完成它,或指不经翻译即可为机器直接理解和接受的程序语言或指令代码。 机器语言使用绝对地址和绝对操作码。 不同的计算机都有各自的机器语言,即指令系统。
- 机器语言与汇编语言关系:
(1)、机器语言(Machine Language)――处理器的指令集及使用它们编写程序的规则。
指令集=指令系统(Instruction Set)――处理器支持的所有指令的集合。
指令(Instruction)――控制计算机完成一个操作的命令。
每个机器指令对应一个二进制数0和1组成的代码(Code),这是处理器能够直接执行的命令。一个机器语言程序就是一段二进制代码序列。
(2)、汇编语言(Assembly Language)――用助记符表示的指令以及使用它们编写程序的规则。
汇编(Assembly)――将汇编语言书写的程序翻译成机器语言程序的过程。
汇编程序(Assembler)――将汇编语言书写的程序翻译成机器语言程序的软件。
不要与汇编语言程序这个说法混淆,后者表示用汇编语言书写的程序,或称汇编语言源程序。
汇编语言是一种符号语言,比机器语言容易理解和掌握、也容易调试和维护。不过汇编语言本质上还是机器语言,还是一种面向机器的低级程序设计语言。
汇编语言是人们比较熟悉的词句直接表述CPU动作形成的语言,是最接近CPU运行原理的语言。每一条汇编语言操作码都可以用机器二进制数据来表示,进而可以将所有的汇编语言(操作码和操作数)和二进制机器语言建立一一映射的关系。
反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码;反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别,如:
(1)分支转移:反汇编的跳转指令用的不是段名称比如.L1,而是用的确定的地址
(2)函数调用:在.s 文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为 hello.c 中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,这些函数的地址不确定,会在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定。
4.5 本章小结
本章介绍了汇编及其过程,通过对可重定位目标elf格式的分析以及对hello.s和hello.o反汇编代码的不同之处的比较,进一步加深了对汇编的理解,掌握了机器语言与汇编语言之间的关系。
第5章 链接5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代 码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。此外,链接将帮助我们构造大型程序,利用共享库。
注意:这儿的链接是指从 hello.o 到hello生成过程。
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 Ubuntu下链接过程
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
5.3 可执行目标文件hello的格式
典型的ELF可执行目标文件格式如下图所示:
-
图5.2 ELF可执行目标文件格式
(1)ELF头:ELF头描述文件的总体格式,他还包括程序的入口点(程序运行时要执行的第一条指令的地址),.text、.rodata和.data节与可重定位目标文件中的节是类似的,除了这些节已经被重定位到它们最终的运行时的内存地址外。.init节定义了一个小函数_init,程序初始化代码会调用它。因为可执行文件时完全连接的,所以无.rel节。
图5.3 ELF头
(3)节头部表:其记录了每个节的名称、偏移量、大小、位置等信息
图5.4 节头部表
(4)符号表 里面存放着程序定义和引用的全局变量的信息,与第三章类似
图5.5 符号表
- 程序头部表:ELF文件被设计的很容易加载到内存,可执行文件的连续的片被映射到连续的内存段,程序头部表描述了这种映射关系。LOAD代表初始化内存段,vddr说明了各段的内存开始地址。
图5.6 程序头部表
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
5.4 hello的虚拟地址空间
hello的Linux x86-64内存映像如下图所示。
图5.8 内存映像
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
-
第一个load,类型声明为只读,猜测为代码段,则从0x400000开始查看,我们发现第一行与ELF头中的Magic是相同的,说明这是代码段的开始。
第四个load,类型声明为读写,猜测为数据段,则从0x400000开始查看,说明这是数据段的开始。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
-
- 链接后函数数量增加。链接后的反汇编文件中,多出了,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。
- 函数调用指令call的参数发生变化。在链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。
- 跳转指令参数发生变化。在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
链接的重定位算法已在第四章提及,下面举一个例子:
在hello.o的重定位条目中exit函数重定位条目为:
是一个重定位绝对引用。带入公式可得:*refptr=ADDR(exit)+r.addend=ffffff6e,与
相同。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
使用edb单步调试运行程序,观察其调用的函数,这里可以发现在调用main之前主要进行了初始化的工作调用了_init,,在这个函数的调用之后是一系列的库函数(printf,exit,atoi等等)之后调用了_start这个就是起始的地址,准备开始执行main的内容,在执行main之后还会执行__libc_csu_init 、__libc_csu_fini 、_fini 最终这个程序才结束。下面大概的列举一下hello跳转和调用的子程序名:
_init
puts@plt
printf@plt
getchar@plt
atoi@plt
exit@plt
sleep@plt
_start
_dl_relocate_static_pie
deregister_tm_clones
register_tm_clones
__do_global_dtors_aux
frame_dummy
main
__libc_csu_init
__libc_csu_fini
_fini
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
对于动态共享链接库中 PIC 函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,GNU 编译系统使用延迟绑定技术,将过程地址的绑定推迟到第一次调用该过程时。使用延迟绑定的动机是对于一个像 libc.so 这样的共享库输出的成百上千个函数中,一个典型的应用程序只会使用其中很少的一部分。把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接的内存引用。延迟绑定是通过两个数据结构的交互来实现的,这两个数据结构是 GOT(全局偏移量表)和 PLT(过程链接表)。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的 GOT 和 PLT。GOT 是数据段的一部分,PLT 是代码段的一部分。下图介绍了 GOT 和 PLT 交互的一个例子。只需注意:GOT 和 PLT 联合使用时,GOT[0]和 GOT[1]包含动态连接器在解析函数地址时会使用的信息。GOT[2]是动态连接器在 ld-linux.so 模块中的入口点(摘抄自csapp)。
-
经过对节头部表的分析,.got.plt 节的起始地址是 0x404000,
程序刚开始,0x404000出的数据为
-
使用 edb 执行至 dl_init后,数据发生了变化:
可以看到 dl_init 后出现了两个地址0x7f10d5cc7190和0x7f10d5cb0ae0,这便是 GOT[1]和 GOT[2]。
5.8 本章小结
本章介绍了链接及其过程,通过对可执行目标文件hello的格式分析,Hello的动态链接分析等,详细阐述了hello.o是怎么链接成为一个可执行目标文件的过程,得到了最终的可执行文件。
(第5章1分)
第6章 hello进程管理6.1 进程的概念与作用
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情 况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数 据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动 过程调用的指令和本地变量。其用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存。
处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个用c语言编写的程序,它是用户使用Linux的桥梁,是一个交互型的应用级程序,它代表用户运行其他程序
功能:shell应用程序提供了一个界面,用户通过访问这个界面访问操作系统内核的服务。 shell执行一系列的读求值步骤,读取来自用户的一个命令行,解析命令行,并代表用户运行程序。
处理流程:
(1)从终端读入输入的命令。
(2)将输入字符串切分获得所有的参数
(3)如果是内置命令则立即执行
(4)否则调用相应的程序执行
(5)shell 应该接受键盘输入信号,并对这些信号进行相应处理
6.3 Hello的fork进程创建过程
首先,要运行hello 程序,需要在shell 输入./hello 7203610501 张烁 1
接下来 shell 会分析这一串命令行:
Hello并不是内置命令,所以 shell 调用 fork()函数,创建一个子进程,这个子进程与父进程几乎没有差别,子进程的虚拟地址空间均与父进程的映射关系一致,是父进程虚拟地址空间的一份副本,包括代码和数据段、堆、共享库以及用户栈。同时,子进程还获得与父进程任何打开文件描述符相同的副本,故此时子进程可以读写父进程打开的任何文件。子进程与父进程的最大差别在于它们有不同的 PID。
6.4 Hello的execve过程
书接上文,父进程fork产生子进程 之后,shell 在子进程中调用 execve 函数,在当前进程的上下文中加载并运行 hello 程序,execve 调用启动加载器来执行 hello 程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容,然后跳转到_start,_start 函数调用系统启动函数__libc_start_main 来初始化环境,调用用户层中hello 的 main 函数,并在需要的时候将控制返回给内核。
图6.1 execve函数
execve 函数加载并运行可执行目标文件 hello,且带参数列表 argv 和环境变量列表 envp。只有当出现错误时,例如找不到 filename,execve 才会返回到调用程序。所以,与 fork—次调用返回两次不同,execve 调用一次并从不返回。
参数列表是用图 6.2 中的数据结构表示的。argv 变量指向一个以 null 结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0] 是可执行目标文件的名字。
图6.2 参数列表
环境变量的列表是由一个类似的数据结构表示的,如图6.3 所示。envp 变量指向一个以 null 结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如 “name=value” 的名字—值对。
图6.3 环境变量的列表
当 main 开始执行时,用户栈的组织结构如图 6.4 所示。让我们从栈底(高地址)往栈顶(低地址)依次看一看。首先是参数和环境字符串。栈往上紧随其后的是以 null 结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串。全局变量 environ 指向这些指针中的第一个 envp[0]o 紧随环境变量数组之后的是以 null 结尾的 argv[] 数组,其中每个兀素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数 libc_start_main的栈帧。
图6.4 用户栈的组织结构
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。上下文切换机制是建立那些较低层异常机制之上的。
内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换 1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个 read 系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是 sleep 系统调用,它显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
中断也可能引发上下文切换。比如,所有的系统都有某种产生周期性定时器中断的机制,通常为每 1 毫秒或每 10 毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。图6.5是一个进程转换的示意图。
-
图6.5 进程转换
接下来分析 hello 的进程调度,hello的一个上下文切换是调用sleep函数时,hello 显式地请求休眠,控制转移给另一个进程,此时计时器开始计时,当计时器到达argv[3],它会产生一个中断信号,中断当前正在进行的进程,进行上下文切换,恢复 hello 在休眠前的上下文信息,控制权回到 hello 继续执行。当循环结束后,hello 调用 getchar 函数,之前 hello 运行在用户模式下,在调用 getchar 时进入内核模式,内核中的陷阱处理程序请求来自键盘缓冲区的 DMA传输,并执行上下文切换,并把控制转移给其他进程。当完成键盘缓冲区到内存的数据传输后,引发一个中断信号,此时内核从其他进程切换回 hello 进程,然后 hello执行 return,进程终止。
6.6 hello的异常与信号处理
hello 执行过程中可能出现四类异常:中断、陷阱、故障和终止。
1. 中断是来自 I/O 设备的信号,异步发生,中断处理程序对其进行处理,返 回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
2. 陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指 令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
3. 故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成 功,则将控制返回到引起故障的指令,否则将终止程序。
4. 终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程 序会将控制返回给一个 abort 例程,该例程会终止这个应用程序。
异常的处理:
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。其中一些号码是由处理器的设计者分配的,其他号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除、缺页、内存访问违例、断点以及算术运算溢出。后者的示例包括系统调用和来自外部 I/O 设备的信号。
在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得表目 k 包含异常 k 的处理程序的地址。在运行时(当系统在执行某个程序时),处理器检测到发生了一个事件,并且确定了相应的异常号 k。随后,处理器触发异常,方法是执行间接过程调用,通过异常表的表目 k,转到相应的处理程序。图 8-3 展示了处理器如何使用异常表来形成适当的异常处理程序的地址。异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊 CPU 寄存器里。图6.6给出了异常的处理方法。
图6.6 异常处理方法
信号处理:
当内核把进程 p 从内核模式切换到用户模式时(例如,从系统调用返回或是完成了一次上下文切换),它会检查进程 p 的未被阻塞的待处理信号的集合(pending &~blocked)。如果这个集合为空(通常情况下),那么内核将控制传递到 p 的逻辑控制流中的下一条指令。然而,如果集合是非空的,那么内核选择集合中的某个信号 k (通常是最小的 k),并且强制 p 接收信号 k。收到这个信号会触发进程采取某种行为。一旦进程完成了这个行为,那么控制就传递回 p 的逻辑控制流中的下一条指令
每个信号类型都有一个预定义的默认行为,是下面中的一种:
进程终止。
进程终止并转储内存。
进程停止(挂起)直到被 SIGCONT 信号重启
进程忽略该信号。
进程可以通过使用 signal 函数修改和信号相关联的默认行为。唯一的例外是 SIGSTOP 和 SIGKILL,它们的默认行为是不能修改的
图6.7 信号处理
下面我们分析一下hello中的异常与信号处理。
- 正常运行:
- CRTL-Z
内核向前台进程发送一个SIGSTP信号,前台job被挂起
jobs: 可以看出运行hello的进程已经停止。
用/bin/kill 向该进程发送信号,发现hello进程以终止,此时再用jobs,没有输出,说明前台作业被清除。
重新键入命令行,ctrl-z,再输入 fg1,被挂起的进程从暂停处,继续执行。
(3)键盘上按下ctrl-C
内核向前台进程发送一个SIGINT信号,前台进程终止,内核再向父进程发送一个SIGCHLD信号,通知父进程回收子进程,此时子进程不再存在
(4)运行途中乱按
运行途中乱按后,只是将乱按的内容输出,程序继续执行,乱按所造成的输入均缓存到stdin,当getchar的时候读一个’\n’结尾的字串。
6.7本章小结
在本章中阐述了进程的定义与作用,同时介绍了 Shell 的一般处理流程和作用,
并且分析了调用 fork 创建新进程,调用 execve函数执行 hello,hello的
进程执行,以及hello 的异常与信号处理。对进程,信号,异常等概念有了跟深入的理解。
第7章 hello的存储管理7.1 hello的存储器地址空间
物理地址(physical address):计算机系统的主存被组织成一个由 M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址(Physical Address,PA)。第一个字节的地址为 0,接下来的字节地址为 1,再下一个为 2,依此类推。给定这种简单的结构,CPU 访问内存的最自然的方式就是使用物理地址。我们把这种方式称为物理寻址(physical addressing)用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相相应。其是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
逻辑地址(Logical Address)是指由程式产生的和段相关的偏移地址部分。表示为 [段标识符:段内偏移量]。
线性地址(Linear Address)地址空间(addressspace)是一个非负整数地址的有序集合:{0,1,2,⋯ }如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间(linear address space)。为了简化讨论,我们总是假设使用的是线性地址空间。其是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
虚拟地址(Virtual Address)虚拟内存为每个程序提供了一个大的、一致的和私有的地址空间。其每个字节对应的地址成为虚拟地址。虚拟地址包括 VPO(虚拟页面偏移量)、VPN(虚拟页号)、TLBI(TLB 索引)、TLBT(TLB 标记)。
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址空间表示:段地址:偏移地址。段地址+偏移地址=线性地址
在实模式下:逻辑地址 CS:EA=CS*16+EA 物理地址
在保护模式下:以段描述符作为下标,到 GDT/LDT 表查表获得段地址,
段内偏移量是在链接后就已经得到的 32 位地址,因此要想由逻辑地址得到线性地址,需要根据逻辑地址的前 16 位获得段地址,这 16 位存放在段寄存器中。
段寄存器(16 位):用于存放段选择符。CS(代码段):程序代码所在段;SS(栈段):栈区所在段;DS(数据段):全局静态数据区所在段;其他三个段寄存器 ES、GS 和 FS 可指向任意数据段。
其中 CS 寄存器中的 RPL 字段表示 CPU 的当前特权级
TI=0,选择全局描述符表(GDT);TI=1,选择局部描述符表(LDT);RPL=00 为第 0 级,位于最高级的内核态;RPL=11 为第 3 级,位于最低级的用户态。高 13 位-8K 个索引用来确定当前使用的段描述符在描述符表中的位置。
图7.2
段描述符是一种数据结构,等价于段表项,分为两类。一类是用户的代码段和数据段描述符,一类是系统控制段描述符。
描述符表:实际上为段表,由段描述符(段表项构成)分为三种类型:
全局描述符表 GDT:只有一个,用来存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及 TSS(任务状态段)等都属于 GDT 中描述的段
局部描述符表 LDT:存放某任务(即用户进程)专用的描述符
中断描述符表 IDT:包含 256 个中断门、陷阱门和任务门描述符
下图展示了逻辑地址到线性地址的转化过程:
图7.4
首先根据段选择符的 TI 部分判断需要用到的段选择符表是全局描述符表还是局部描述符表,随后根据段选择符的高 13 位的索引(描述符表偏移)到对应的描述符表中找到对应的偏移量的段描述符,从中取出 32 位的段基址地址,将 32 位的段基址地址与 32 位的段内偏移量相加得到 32 位的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
Linux下,虚拟地址到物理地址的转化与翻译是依靠页式管理来实现的,虚拟内存作为内存管理的工具。概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。磁盘上数组的内容被缓存在物理内存中(DRAM cache)这些内存块被称为页 (每个页面的大小为P = 2p字节)。
而分页机制的作用就是通过将虚拟和物理内存分页,并且通过MMU建立起相应的映射关系,可以充分利用内存资源,便于管理。一般来说一个页面的标准大小是4KB,有时可以达到4MB。而且虚拟页面作为磁盘内容的缓存,有以下的特点:DRAM缓存为全相联,任何虚拟页都可以放置在任何物理页中需要一个更大的映射函数,不同于硬件对SRAM缓存更复杂精密的替换算法太复杂且无限制以致无法在硬件上实现DRAM缓存总是使用写回,而不是直写。
虚拟页面地集合被分为三个不相交的子集:已缓存、未缓存和未分配。
图7.5 虚拟页面
页表实现从虚拟页到物理页的映射,依靠的是页表,页表就是是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址。这个页表是常驻与主存中的。
图7.6 页表
下图展示了页式管理中虚拟地址到物理地址的转换:
图7.7 转换
下图a展示了当页面命中时,CPU硬件执行的步骤:
第1步:处理器生成一个虚拟地址,并把它传送给MMU;
第2步:MMU生成PTE地址,并从高速缓存/主存请求得到它;
第3步:高速缓存/主存向MMU返回PTE;
第4步:MMU构造物理地址,并把它传送给高速缓存/主存;
第5步:高速缓存/主存返回所请求的数据字给处理器
图7.8
处理缺页如图b所示:
第1~3步:和图a中的第1步到第3步相同;
第4步:PTE中的有效位是零,所以MMU触发了一次异常,传给CPU中的控制到操作系统内核中的缺页异常处理程序;
第5步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘;
第6步:缺页处理程序页面调入新的页面,并更新内存中的PTE;
第7步:缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,在MMU执行了图b中的步骤之后,主存就会将所请求字返回给处理器。
7.4 TLB与四级页表支持下的VA到PA的变换
正如我们看到的,每次 CPU 产生一个虚拟地址,MMU 就必须查阅一个 PTE,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会要求从内存多取一次数据,代价是几十到几百个周期。如果 PTE 碰巧缓存在 L1 中,那么开销就下降到 1 个或 2 个周期。然而,许多系统都试图消除即使是这样的开销,它们在 MMU 中包括了一个关于 PTE 的小的缓存,称为翻译后备缓冲器(Translation Lookaside Buffer,TLB)。
TLB 是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个 PTE 组成的块。TLB 通常有高度的相联度。如图 9-15 所示,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果 TLB 有
T=2t\small T = 2^tT=2t
个组,那么 TLB 索引(TLBI)是由 VPN 的 t 个最低位组成的,而 TLB 标记(TLBT)是由 VPN 中剩余的位组成的。
-
当 TLB 命中时(通常情况)所包括的步骤。这里的关键点是,所有的地址翻译步骤都是在芯片上的 MMU 中执行的,因此非常快。
第 1 步:CPU 产生一个虚拟地址。
第 2 步和第 3 步:MMU 从 TLB 中取出相应的 PTE。
第 4 步:MMU 将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
第 5 步:高速缓存/主存将所请求的数据字返回给 CPU。
图7.9
当 TLB 不命中时,MMU 必须从 L1 缓存中取出相应的 PTE,如图 7.9b 所示。新取出的 PTE 存放在 TLB 中,可能会覆盖一个已经存在的条目。
图7.10
在四级页表层次结构的地址翻译中,虚拟地址被划分为 4 个 VPN 和 1 个 VPO。每个 VPNi 都是一个到第 i 级页表的索引,第 j 级页表中的每个 PTE 都指向第 j+1级某个页表的基址,第四级页表中的每个 PTE 包含某个物理页面的 PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定 PPN 之前,MMU 必须访问四个PTE。
图7.11
访问 k 个 PTE,第一眼看上去昂贵而不切实际。然而,这里 TLB 能够起作用,正是通过将不同层次上页表的 PTE 缓存起来。实际上,带多级页表的地址翻译并不比单级页表慢很多。
7.5 三级Cache支持下的物理内存访问
下图总结了完整的 Core i7 地址翻译过程,从 CPU 产生虚拟地址的时刻一直到来自内存的数据字到达 CPU。Core i7 采用四级页表层次结构。每个进程有它自己私有的页表层次结构。当一个 Linux 进程在运行时,虽然 Core i7 体系结构允许页表换进换出,但是与已分配了的页相关联的页表都是驻留在内存中的。CR3 控制寄存器指向第一级页表(L1)的起始位置。CR3 的值是每个进程上下文的一部分,每次上下文切换时,CR3 的值都会被恢复。首先,根据物理地址的 s 位组索引索引到 L1 cache中的某个组,然后在该组中查找是否有某一行的标记等于物理地址的标记并且该行的有效位为 1,若有,则说明命中,从这一行对应物理地址 b 位块偏移的位置取出一个字节,若不满足上面的条件,则说明不命中,需要继续访问下一级 cache,访问的原理与 L1 相同,若是三级 cache 都没有要访问的数据,则需要访问内存,从内存中取出数据并放入cache。
图7.11
7.6 hello进程fork时的内存映射
既然我们理解了虚拟内存和内存映射,那么我们可以清晰地知道 fork 函数是如何创建一个带有自己独立虚拟地址空间的新进程的。
当 fork 函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID。为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用 fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:
1. 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的 区域结构。
2. 映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构, 所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3. 映射共享区域,hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到 这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4. 设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下 文的程序计数器,使之指向代码区域的入口点。
图7.12
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM 缓存不命中称为缺页(page fault)。图 7.13 展示了在缺页之前我们的示例页表的状态。CPU 引用了 VP 3 中的一个字,VP 3 并未缓存在 DRAM 中。地址翻译硬件从内存中读取 PTE 3,从有效位推断出 VP 3 未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在 PP 3 中的 VP 4。如果 VP 4 已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改 VP 4 的页表条目,反映出 VP 4 不再缓存在主存中这一事实。
图7.14
接下来,内核从磁盘复制 VP 3 到内存中的 PP 3,更新 PTE 3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP 3 已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。图 7.15 展示了在缺页之后我们的示例页表的状态。
图7.15
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
虽然可以使用低级的 mmap 和 munmap 函数来创建和删除虚拟内存的区域,但是 C 程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器(dynamic memory allocator)更方便,也有更好的可移植性。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)(见图 9-33)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量 brk(读做 “break”),它指向堆的顶部。
图7.16
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。例如,C 标准库提供一种叫做 malloc 程序包的显式分配器。C 程序通过调用 malloc 函数来. 分配一个块,并通过调用 free 函数来释放一个块。C++ 中的 new 和 delete 操作符与 C 中的 malloc 和 free 相当。
隐式分配器(implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbage collection)。例如,诸如 Lisp、ML 以及 Java 之类的高级语言就依赖垃圾收集来释放已分配的块。
放置已分配的块:当一个应用请求一个互字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略(placement policy)确定的。一些常见的策略是首次适配(firstfit),下一次适配(nextfit)和最佳适配(bestfit)„
首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
分割空闲块:一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间。一个选择是用整个空闲块。虽然这种方式简单而快捷,但是主要的缺点就是它会造成内部碎片。如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可以接受的。
然而,如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块,而剩下的变成一个新的空闲块
图7.17
获取额外的堆内存:如果分配器不能为请求块找到合适的空闲块将发生什么呢?一个选择是通过合并那些在内存中物理上相邻的空闲块来创建一些更大的空闲块(在下一节中描述)。然而,如果这样还是不能生成一个足够大的块,或者如果空闲块已经最大程度地合并了,那么分配器就会通过调用 sbrk 函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。
合并空闲块:为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并(coalescing)。这就出现了一个重要的策略决定,那就是何时执行合并。分配器可以选择立即合并(immediate coalescing),也就是在每次一个块被释放时,就合并所有的相邻块。或者它也可以选择推迟合并(deferred coalescing),也就是等到某个稍晚的时候再合并空闲块。例如,分配器可以推迟合并,直到某个分配请求失败,然后扫描整个堆,合并所有的空闲块。
立即合并很简单明了,可以在常数时间内执行完成,但是对于某些请求模式,这种方式会产生一种形式的抖动,块会反复地合并,然后马上分割。
图7.18
7.10本章小结
本章介绍了hello的存储管理机制。讨论了虚拟地址、线性地址、物理地址,介绍了段式管理与页式管理、VA 到 PA 的变换、物理内存访问,以及 hello 进程 fork 、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等。对虚拟地址有了更深入的理解。
第8章 hello的IO管理8.1 Linux的IO设备管理方法
(以下格式自行编排,编辑时删除)
设备的模型化:文件
设备管理:unix io接口
一个 Linux 文件就是一个 m 个字节的序列:
,所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
8.2 简述Unix IO接口及其函数
接口:
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)和标准错误(描述符为 2)。头文件 <unistd.h> 定义了常量 STDIN_FILENO、STDOUT_FILENO 和 STDERR_FILENO,它们可用来代替显式的描述符值。
改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置 k,初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行 seek 操作,显式地设置文件的当前位置为 k。
读写文件。一个读操作就是从文件复制 n > 0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k+n。给定一个大小为 m 字节的文件,当
k⩾m时执行读操作会触发一个称为 end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的 “EOF 符号”。
类似地,写操作就是从内存复制 n > 0 个字节到一个文件,从当前文件位置 k 开始,然后更新 k。
关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
函数:
- int open(char* filename,int flags,mode_t mode)
进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
- int close(fd)
fd是需要关闭的文件的描述符,close返回操作结果。
- ssize_t read(int fd,void *buf,size_t n)
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
- ssize_t wirte(int fd,const void *buf,size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
查看windows系统下的printf函数:
1
static int printf(const char *fmt, ...)
2
{
3
va_list args;
4
int i;
5
6
va_start(args, fmt);
7
write(1,printbuf,i=vsprintf(printbuf, fmt, args));
8
va_end(args);
9
return i;
10
}
图 8.1 printf的函数体
其中va_list的定义:typedef char *va_list,这说明它是一个字符指针。其中的: (char*)(&fmt) + 4) 表示的是...中的第一个参数。C语言中,参数压栈的方向是从右往左。也就是说,当调用printf函数的适合,先是最右边的参数入栈。
fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。
fmt也是个变量,它的位置,是在栈上分配的,它也有地址。对于一个char *类型的变量,它入栈的是指针,而不是这个char *型变量。
va_list的定义:typedef char *va_list,说明它是一个字符指针,其中 (char*)(&fmt) + 4) 即arg表示的是...中的第一个参数。再进一步查看vsprintf函数体:
图 8.2 vsprintf的函数体
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
printf函数的功能为接受一个格式化命令,并按指定的匹配的参数格式化输出,故i = vsprintf(buf, fmt, arg)是得到打印出来的字符串长度,其后的write(buf, i)是将buf中的i个元素写到终端。
因此,vsprintf的作用为接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,进而产生格式化输出。
再进一步对write进行追踪:
图 8.3 write
这里给几个寄存器传递了参数,然后以一个int INT_VECTOR_SYS_CALL结束。INT_VECTOR_SYS_CALL代表通过系统调用syscall,查看syscall的实现:
-
-
图 8.4 syscall的情况
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要讲述了Linux的I/O设备管理方法,Unix I/O接口及其函数,以及printf函数实现的分析和getchar函数的实现。对 Unix 操作系统提供的基本 I/O 服务有了更深入的理解。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello程序的一生经历了如下过程:
1.hello 程序的生命周期是从一个源程序(或者说源文件)开始的,即程序员通过编辑器创建并保存的文本文件,文件名是 hello.c
2.预处理
完成诸如将hello.c中include的所有外部的头文件头文件内容直接插入程序文本中的操作;
2.编译
编译器将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。其以高级程序设计语言书写的源程序作为输入,而以汇编语言表示的目标程序作为输出。
3.汇编
将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o 目标文件中;
4.链接
通过链接器,将hello的程序编码与动态链接库等整理成为一个单一文件,可执行的目标文件hello;
5.加载运行
打开Shell,在其中键入命令行,SHELL fork新建进程,并通过execve把代码和数据加载入内存空间,程序开始执行;
6.上下文切换:hello调用sleep函数之后发生进程的切换,内核进行上下文切换将当前进程的控制权交给其他进程,当sleep函数调用完成时,内核执行上下文切换再将控制传递给当前进程。
7.动态申请内存:当hello程序执行printf函数时,会调用 malloc 函数动态申请内存。
8.终止:当子进程执行完成时,父进程会回收子进程,同时会删除为这个进程创建的所有数据结构。自此,Hello的使命完成了。若以后不在调用这个程序,hello.c就会静静的在硬盘中等待。
回顾本次做大作业的过程,也是大概的把caspp这本书又看了一遍,不由得感到计算机系统的精妙。计算机确实是人类集体智慧的结晶。精彩的指令系统,高超的进程,虚拟存储理论,让人感叹计算机科学工作者的伟大。即使是在阅读csapp教材之后,也无法彻底理解计算机系统的设计,更妄谈提出新的创新理念,但是,我将会保持一颗谦虚的心,在学习计算机的道路上,时时拜读这本鸿篇巨制,感悟计算机设计的精妙,同时要不忘初心,不断追寻用技术造福人类的方法。
附件列出所有的中间产物的文件名,并予以说明起作用。
hello.c
hello源代码
hello.i
预处理之后的文本文件
hello.s
hello的汇编代码
hello1.s
hello.o的反汇编代码
hello2.s
hello的反汇编代码
hello.o
hello的可重定位文件
hello
hello的可执行文件
参考文献为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
[7] 深入理解计算机系统原书第3版-文字版.pdf
https://hansimov.gitbook.io/csapp/
- printf 函数实现的深入剖析 - Pianistx - 博客园 (cnblogs.com) [转]printf 函数实现的深入剖析 - Pianistx - 博客园
- 链接器,符号解析与重定位-概念_diaoju3333的博客-CSDN博客 链接器,符号解析与重定位-概念_diaoju3333的博客-CSDN博客
- (27条消息) ELF文件查看利器之readelf和objdump用法_天健胡马灵越鸟的博客-CSDN博客_readelf 查看汇编
https://blog.csdn.net/pansaky/article/details/88656420