程序人生-Hello’s P2P

摘  要

本论文以拟人化的手法着眼hello程序在Linux系统下的一生,分析了其从C语言源代码到可执行目标代码再到最终被运行在进程中的整个流程。文章从软件和硬件两个方面结合来观察计算机系统如何运行一个程序,对计算机系统的工作方式做了详细的研究。

关键词:计算机系统;编译;Linux系统;汇编语言                           

目  录

第1章 概述... - 4 -

1.1 Hello简介... - 4 -

1.2 环境与工具... - 4 -

1.3 中间结果... - 4 -

1.4 本章小结... - 5 -

第2章 预处理... - 6 -

2.1 预处理的概念与作用... - 6 -

2.2在Ubuntu下预处理的命令... - 6 -

2.3 Hello的预处理结果解析... - 7 -

2.4 本章小结... - 7 -

第3章 编译... - 8 -

3.1 编译的概念与作用... - 8 -

3.2 在Ubuntu下编译的命令... - 8 -

3.3 Hello的编译结果解析... - 9 -

3.3.1 常量... - 9 -

3.3.2 变量... - 10 -

3.3.3 赋值... - 11 -

3.3.4 算术操作... - 11 -

3.3.5 关系操作... - 11 -

3.3.6 控制转移... - 12 -

3.3.7 函数调用... - 12 -

3.4 本章小结... - 13 -

第4章 汇编... - 14 -

4.1 汇编的概念与作用... - 14 -

4.2 在Ubuntu下汇编的命令... - 14 -

4.3 可重定位目标elf格式... - 14 -

4.4 Hello.o的结果解析... - 17 -

4.5 本章小结... - 20 -

第5章 链接... - 21 -

5.1 链接的概念与作用... - 21 -

5.2 在Ubuntu下链接的命令... - 21 -

5.3 可执行目标文件hello的格式... - 22 -

5.4 hello的虚拟地址空间... - 23 -

5.5 链接的重定位过程分析... - 24 -

5.6 hello的执行流程... - 27 -

5.7 Hello的动态链接分析... - 29 -

5.8 本章小结... - 30 -

第6章 hello进程管理... - 31 -

6.1 进程的概念与作用... - 31 -

6.2 简述壳Shell-bash的作用与处理流程... - 31 -

6.3 Hello的fork进程创建过程... - 31 -

6.4 Hello的execve过程... - 32 -

6.5 Hello的进程执行... - 32 -

6.6 hello的异常与信号处理... - 32 -

6.7本章小结... - 36 -

第7章 hello的存储管理... - 37 -

7.1 hello的存储器地址空间... - 37 -

7.2 Intel逻辑地址到线性地址的变换-段式管理... - 37 -

7.3 Hello的线性地址到物理地址的变换-页式管理... - 37 -

7.4 TLB与四级页表支持下的VA到PA的变换... - 38 -

7.5 三级Cache支持下的物理内存访问... - 39 -

7.6 hello进程fork时的内存映射... - 39 -

7.7 hello进程execve时的内存映射... - 40 -

7.8 缺页故障与缺页中断处理... - 40 -

7.9动态存储分配管理... - 40 -

7.10本章小结... - 41 -

第8章 hello的IO管理... - 42 -

8.1 Linux的IO设备管理方法... - 42 -

8.2 简述Unix IO接口及其函数... - 42 -

8.3 printf的实现分析... - 43 -

8.4 getchar的实现分析... - 44 -

8.5本章小结... - 44 -

结论... - 45 -

附件... - 46 -

参考文献... - 47 -

第1章 概述

1.1 Hello简介

P2P(Program to Process),意为从程序到进程。一开始我们有一段代码hello.c,编译器驱动程序首先运行C预处理器ccp将源程序翻译成一个ASCII码的中间文件hello.i;接下来C编译器cc1将hello.i翻译成一个ASCII汇编语言文件hello.s;然后汇编器as将hello.s翻译成一个可重定位目标文件hello.o;最后,连接器程序ld将hello.o与需要用到的其他目标文件以及一些必要的系统目标文件组合起来,创建一个可执行目标文件hello。

现在我们可以运行生成的hello文件,在Linux shell中输入“./hello”,父shell进程将为其fork一个子进程,并通过execve在子进程的上下文中加载并运行hello程序。至此,hello完成了从Program到Process的过程。

020(From Zero to Zero),指的是hello这个进程,一开始不存在于内存中,当运行完毕后,终止的hello进程又被父shell进程通过调用waitpid等方式回收,不再存留在内存里,回到了“Zero”的状态。

1.2 环境与工具

硬件环境:

X64 CPU;4GHz;8G RAM;256G HD Disk

软件环境:

Windows10 64位;VirtualBox;Ubuntu 16.04 LTS 64位

开发与调试工具:

Visual Studio 2017 64位;CodeBlocks 64位;vi/vim/gedit+gcc

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

hello.i:预处理文件,由源程序经预处理器翻译而来

hello.s:汇编语言文件,由预处理文件经编译器翻译而来

hello.o:可重定位目标文件,由汇编文件通过汇编器翻译而来

1.4 本章小结

       Hello是一个非常简单的程序,但是要让它成功地在计算机上运行起来并不容易,P2P(From Program to Process)是一个复杂且环环相扣的过程,只有通过了一次次翻译加工才能得到最终的可执行程序。而这之后,要启动可执行程序使它成为一个“活着”的进程,再到运行完成后将其抹去,都需要计算机系统精密而细致的操作,我们将深入剖析这些过程,了解在每一个步骤中都发生了什么。

第2章 预处理

2.1 预处理的概念与作用

预处理是指预处理器cpp根据以“#”开头的命令(宏定义、条件编译等),修改、完善原始的C程序,进行引入头文件、修改宏常量等操作。

当执行#include包含头文件的指令时,预处理器会把头文件内的指令包含进我们的源文件中,所以执行过后代码的行数会大大增加。而#define定义的常量在预处理阶段会被全部替换。

另外,预处理阶段还会对代码中的所有注释进行删除。

2.2在Ubuntu下预处理的命令

预处理命令为:gcc – E hello.c -o hello.i

图2-1 输入预处理指令

图2-2 生成的hello.i文件

2.3 Hello的预处理结果解析

图2-3 hello.i文件末尾

经过预处理后得到一个文本文件hello.i,打开查看可以发现这个文件有3000多行,比源程序多了很多,多出来的部分主要是被引用的stdio.h以及stdlib.h库。在文件末尾可以看到我们编写的main函数,注释已经被删去。

2.4 本章小结

本章介绍了预处理的概念,并展示了源程序经过预处理之后的变化,解析了经过预处理后得到的hello.i文件的内容。

第3章 编译

3.1 编译的概念与作用

编译阶段,编译器cc1主要通过词法分析、语法分析、语义分析等操作,将上一步得到的hello.i翻译成文本文件hello.s,它包含了一个汇编语言程序。汇编语言的每条语句即为一条低级机器语言指令,对应着CPU等硬件的一个动作。

不同高级语言经由各自的编译器生成通用的汇编语言,为后面进一步处理做好了铺垫。

3.2 在Ubuntu下编译的命令

命令:gcc -S hello.i -o hello.s

图3-1 输入编译命令

图3-2 生成的hello.s文件

3.3 Hello的编译结果解析

3.3.1 常量

在hello.c中,出现常量的地方有如下几处:

1.判断if(argc != 4)。在生成的汇编语言中,4直接以立即数的形式出现在一行语句中。

图3-3 将argc与常量4进行比较

2.printf输出的两个字符串常量"用法: Hello 学号 姓名 秒数!\n",以及"Hello %s %s\n"。在汇编代码中用标签LC0和LC1来表示这两个字符串常量。

图3-4 定义字符串常量

3.for循环判断循环次数i < 8。汇编代码中将i的值与立即数7比较,实际操作是判断i ≤ 7。

图3-5 判断循环条件i ≤ 7

3.3.2 变量

hello.c中出现的变量有argc、argv和i。它们被存放在栈中,我们可以在汇编语言中找到他们具体存放的位置。

通过该比较语句可知,argc存放在-20(%rbp)。

图3-6 argc与4进行比较

类似的,我们可以知道变量i存放的位置是-4(%rbp)。

图3-7 i与7进行比较

对于数组char *argv[],我们可以通过printf输出前的两个赋值语句,经过简单的计算找到argv[1]和argv[2]的位置,即-16(%rbp)和-24(%rbp)。

图3-8 给printf输出的参数赋值

同理可以得到argv[3]的存放位置是-8(%rbp)。

图3-9 给atoi函数的参数赋值

3.3.3 赋值

hello.c中在for循环中有一次对变量i的赋值i = 0。在汇编语言中被翻译为了一条赋值语句,movl表示操作数的长度为4字节。

图3-10 为变量i赋值0

3.3.4 算术操作

在每次循环结束后,会对变量i进行自增操作i++。在汇编语言中,使用addl指令完成这一动作。

图3-11 将i的值增加1

3.3.5 关系操作

hello.c中共包含两个比较语句,分别是比较argc和4以及i和8的大小。在汇编语言中使用cmpl指令进行大小的比较,该指令会根据两个操作数之差来设置CPU中的条件码。

图3-12 比较大小的汇编语句

3.3.6 控制转移

hello.c的if和for语句都会用到控制转移,在汇编语言中通过跳转指令来将执行切换到程序中的一个新位置,具体是否进行跳转将根据条件码的值决定,在这里,也就是根据跳转指令前cmpl指令的执行结果而决定。

 

图3-13 if判断后的跳转语句

 

图3-14 每次for循环中的跳转语句

3.3.7 函数调用

hello.s汇编代码中使用call指令进行函数的调用。

 

图3-15 使用call指令调用函数

3.4 本章小结

本章主要分析了从hello.i到hello.s的编译环节。具体解析了编译器是如何将C语言代码转换为汇编语言指令的,从汇编指令的常量、变量、赋值、算术操作、关系操作、控制转移以及函数调用等方面深入分析了编译器将C语言翻译为汇编语言的方式。

第4章 汇编

4.1 汇编的概念与作用

汇编是指汇编器as将hello.s翻译成机器语言指令,把这些指令打包成一种叫可重定位目标程序的格式,并将结果保存在二进制的目标文件hello.o中。

通过汇编的过程,汇编语言被翻译成机器语言,用二进制码代替汇编语言中的符号,让它成为机器可以直接识别的程序。

4.2 在Ubuntu下汇编的命令

命令:as hello.s -o hello.o

图4-1 在命令行中输入汇编指令

4.3 可重定位目标elf格式

本节将分析hello.o的ELF格式,用readelf列出其各节的基本信息,特别是重定位项目分析。

使用readelf -a hello.o指令查看hello.o的ELF文件各节基本信息。

首先是ELF头,它以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。

图4-2 EFL头的主要内容

然后是节头部表,它描述了不同节的位置和大小,目标文件中的每一个节都有一个固定大小的条目。

图4-3 节头部表

其中.text是已编译程序的机器代码。

.rela.text是一个.text节中位置的列表。当链接器把这个目标文件和其他文件组合时,需要修改这些位置。

.data是已初始化的全局和静态C变量。

.bss是未初始化的全局和静态变量。

.rodata是只读数据。

.strtab是一个字符串表,内容包括.symtab节中的符号表以及节头部中的节名字。

下面对重定位项目进行分析:

图4-4 重定位项目信息

如上图,Offset是需要被修改的引用的字节偏移(在代码节或数据节的偏移),Info指示了重定位目标在.symtab中的偏移量和重定位类型,Type表示不同的重定位类型,例如图中R_X86_64_PC32就表示重定位一个使用32位PC相对地址的引用。Sym. Name表示被修改引用应该指向的符号,Append用于一些类型的重定位要使用它对被修改引用的值做偏移调整。

从图中我们可知,在链接时需要对.rodata中的两个用于printf输出的字符串常量,函数puts,exit,printf,sleep以及getchar进行重定位。

4.4 Hello.o的结果解析

(以下格式自行编排,编辑时删除)

objdump -d -r hello.o  分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。

说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。

输入指令objdump -d -r hello.o得到hello.o的反汇编结果。

图4-5 对hello.o进行反汇编

得到的反汇编代码从main开始,第一行语句的地址为0,后面的每一行语句都被分配了相应的地址,即图中每一行最左侧的16进制数。紧随其后的是一组由1个或多个十六进制字节值构成的指令,包含了操作码和操作数。最右边是这些指令对应的汇编语言代码。

图4-6 hello.s与反汇编结果对照

将反汇编的结果与hello.s的汇编语言代码进行比较,两者大体上一致,但也可以发现存在着很多不同之处。

图4-7 hello.s与反汇编代码的不同之处

首先,对于静态常量的引用,在hello.s中使用的是标签名加%rip的值,而反汇编代码中使用0加%rip的值。这是因为我们反汇编的是可重定位文件,常量的位置需要经过重定位才能确定,故暂时用0来代替。

其次,hello.s中的跳转指令使用某个段的标签(如.L3)作为跳转的目的地,而反汇编代码中则直接使用需要跳转的地址。

图4-8 函数调用的区别

另外,hello.s通过函数名来调用函数,而反汇编代码中调用函数的地址总是下一条指令的地址。这是因为hello.o还未经重定向,还无法确定函数符号所对应的地址,而默认的偏移量为0,故显示的引用地址就是PC的值。当经过重定向后,call指令就会以具体需要调用函数的有效起始地址作为操作数。对于这些引用,汇编器产生了一个重定位条目,显示在引用的后面一行。即图中的“21:R_X86_64_PLT32    puts-0x4”。

4.5 本章小结

本章分析了从hello.s到hello.o的汇编过程。对于汇编生成的hello.o的EFL格式文件进行了分析,观察了其中包含的可重定位项目信息。此外,我们还通过反汇编hello.o得到汇编语言代码,并将其与hello.s中的内容进行比较,更加深入地了解了在汇编的过程中代码发生了何种变化。

5章 链接

5.1 链接的概念与作用

链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。链接可以执行于编译时、加载时,甚至执行于运行时。在现代系统中,链接是由名为链接器的程序自动执行的。

链接器使得分离编译成为可能。我们可以不用将一个大型的应用程序组织为一个巨大的源文件,而是把它分解为更小的可独立地修改和编译的模块。当改变这些模块中的一个时,只需简单重新编译它,并重新链接应用,不必重新编译其他文件,节省了许多不必要的工作量。

5.2 在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-1 输入ld链接指令

5.3 可执行目标文件hello的格式

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

使用readelf -a hello查看hello的ELF格式信息。

图5-2 hello的ELF文件中节头部表的信息

首先我们可以看到节头部表中的信息,第一列Name/Size是各段的名字以及大小;第三列Address是各段的起始地址;最后一列Offset则是各段的偏移量。

图5-3 hello的ELF文件中程序头部表的信息

紧接着是程序头部表的信息。我们可以看到根据可执行目标文件的内容会初始化多个内存段。与图中显示的一致,Linux x86-64系统中代码段总是从地址0x400000开始,我们还可以知道第一个代码段的大小是0x2a0字节,我们对其有读的访问权限。

5.4 hello的虚拟地址空间

使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。

图5-4 edb中Data Dump中信息与ELF格式对照

在edb的Data Dump窗口中,我们找到了与ELF中代码段虚拟地址相同的地址,且第一个代码段从地址0x400000处开始。

图5-5 edb Symbols Viewer中的信息对照

打开edb中的Symbols Viewer,可以发现ELF中Program Headers各段的信息都有与之一一对应的内容。

5.5 链接的重定位过程分析

objdump -d -r hello分析hello与hello.o的不同,说明链接的过程。

结合hello.o的重定位项目,分析hello中对其怎么重定位的。

输入指令objdump -d -r hello,得到hello的反汇编结果。

图5-6 hello的反汇编结果

图5-7 main函数前面的部分

首先,hello的反汇编结果比hello.o的反汇编结果多了很多内容,在main函数前面还有很多其他的函数段,这是链接的结果,在链接的过程中,其他库中的的内容被添加进了hello中。

图5-8 main起始地址的变化

另外,在hello的反汇编结果中,main函数的起始地址不再是0,而是经过重定位被分配了一个具体的虚拟地址,其他指令的地址也同样发生了变化。相应的,跳转指令的跳转地址也发生了改变,保证跳转后执行的指令是正确的。

图5-9 静态变量地址和调用指令的变化

对于静态变量的引用,经过链接重定位后指向了具体被分配的内存地址,而不再是默认的0值。函数名的引用也被重定位,因此调用指令现在将正确地指向被调用函数的起始地址,而不是PC中下一条指令的地址值。

总结而言,在链接的过程中完成了符号解析和重定位两个任务。hello.o中从地址0开始的代码和数据节经过重定位,有了具体的内存地址,而代码中对于各种符号的引用也均被修改,正确地指向了符号定义的内存位置。

5.6 hello的执行流程

先通过objdump反汇编hello,找出其中的关键函数,再通过gdb运行hello并在每个函数入口处设置断点,即可追踪hello的执行流程。

图5-10 在gdb中设置好的断点

图5-11 hello的执行流程1

图5-12 hello的执行流程2

图5-13 hello的执行流程3

以上三张截图展示了hello的整个运行流程。程序从_init函数开始,到_fini结束,图中显示的蓝色数值为调用的函数对应地址,其后黄色字体即为该地址对应的函数名。我们还可以发现hello在执行中还跳转到了ioputs.c和exit.c并分别执行了其中的__GI__IO_puts和__GI_exit函数。

5.7 Hello的动态链接分析

分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。

当程序调用一个由共享库定义的函数时,编译器无法预测这个函数运行时的地址,对此编译系统将使用延迟绑定技术,将过程地址的绑定推迟到第一次调用该过程时。这项技术通过GOT和过程链接表PLT的协作来解析函数的地址。通过edb,我们可以得知在运行时.got.plt节发生的变化。

图5-14 通过ELF文件查找.got.plt节的地址

通过ELF文件我们可以得知,.got.plt节的地址为0x404000。我们在edb中找到该地址的值,观察其值在运行前后的变化。

图5-15 运行前

图5-16 运行后

5.8 本章小结

本章讲述了链接的概念及作用,对链接后生成的可执行目标文件hello做了多方面的分析:观察并解释了hello的ELF格式文件,对比了链接前后文件发生的变化,还追踪了hello运行时的调用跳转流程,并观察了动态链接如何在程序运行的过程中发挥作用。

6章 hello进程管理

6.1 进程的概念与作用

进程就是一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中。进程的上下文由程序正确运行所需的状态组成,包括内存中程序的代码和数据、程序的栈、寄存器的内容、程序计数器以及环境变量等。通过进程,我们的程序可以像独占处理器和内存似的运行,这是因为进程提供给程序一个独立的逻辑控制流和一个私有的地址空间。

6.2 简述壳Shell-bash的作用与处理流程

       Shell即“壳”,是一个相对于内核的概念。Shell建立在内核的基础上,是一个面向用户的命令接口,表现形式就是一个可以接受用户输入的界面,这个界面也可以输出运行信息。

       Shell在运行时的处理流程大致如下:首先读取从键盘输入的命令并判断命令是否正确,且将命令行的参数改造为系统调用execve内部处理所要求的形式。终端进程调用fork来创建子进程,自身则用系统调用wait来等待子进程完成。但如果命令行末尾有后台命令符号&,终端进程则不会等待子进程运行结束,而是立即发提示符,让用户输入下一条命令。当子进程完成处理后,会向父进程发送消息等待被回收,此时终端进程被唤醒,再次输出提示符,等待用户输入新命令。

6.3 Hello的fork进程创建过程

父进程通过调用fork函数创建一个新的运行的子进程。调用fork函数后,新创建的子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同的但是独立的一份副本,包括代码、数据段、堆、共享库以及用户栈。子进程获得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。fork被调用一次,却返回两次,子进程返回0,父进程返回子进程的PID。子进程有不同于父进程的PID。

对于hello程序,当我们在终端输入命令./hello时,shell会对命令进行解析,首先由于hello不是内置命令,因此shell会fork一个子进程,并在当前目录下寻找可执行文件hello运行。

6.4 Hello的execve过程

exceve函数在当前进程的上下文中加载并运行一个新程序。当hello的进程被创建后,exceve函数将在该进程中加载并运行hello的程序,并且如果我们运行时输入了参数,这些参数也会被传递给execve函数,进而供hello的程序使用。Execve函数在当前进程加载并运行一个新程序后会覆盖当前进程的地址空间,但新程序会继承先前打开的所有文件描述符,新程序的PID也不会发生改变。

6.5 Hello的进程执行

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

系统中通常有许多程序在运行,不同程序的进程会轮流使用处理器,这就是多任务的概念。当轮到我们hello的进程执行时,它会执行自己的逻辑控制流的一部分然后被抢占,成为暂时挂起的状态,这个时候CPU会去运行其他程序。每个被执行的控制流部分的时间段即为时间片。

操作系统内核使用上下文切换的异常控制流来实现多任务。内核为每一个进程维持一个上下文,上下文是内核重新启动一个被抢占的进程所需的各种状态,在上下文切换时内核会保存当前进程的上下文并恢复某个先前被抢占的进程被保存的上下文,然后再将控制传递给这个被恢复的进程。在进程执行的某些时刻,内核可以抢占当前进程并重新开始一个先前被抢占的进程,这种决策被称为调度。

用户模式和内核模式是为内核提供保护以及权限的一种记住。处理器用某个控制寄存器中的一个模式位来限制一个应用可以执行的指令以及它可以访问的地址空间范围。没有设置模式位时,进程运行在用户模式中,它必须通过系统调用接口才可间接访问内核代码和数据;而设置了模式位后,它运行在内核模式中,可以执行指令集中的任何指令,访问系统内存的任何位置。

进程从用户模式变为内核模式的唯一方法是通过像中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式,令异常处理程序运行在内核模式中,当它返回到应用程序代码时,处理器又把模式改回到用户模式。

6.6 hello的异常与信号处理

hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。

程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps  jobs  pstree  fg  kill 等命令,请分别给出各命令及运行截屏,说明异常与信号的处理。

图6-1 运行时按下多个回车

运行时按下回车会换一行,但不影响程序的正常执行。因为当程序执行时会阻塞这些操作产生的信号,不会受到外部输入的影响。而因为输入了回车,所以程序最后调用getchar会直接读取一个还未被读取的回车符并自动结束,不再需要通过新输入来结束程序。剩余还未被读取的回车符将被shell读取,由于并不会产生什么结果,因此shell只是在读取后换行并输出新的命令提示符。

图6-2 运行时输入Ctrl-Z

程序运行时按Ctrl-Z产生中断异常,它的父进程会接收到信号SIGSTP并运行信号处理程序,然后便发现程序在这时被挂起了,并打印了相关挂起信息。输入ps和jobs指令可以看到被挂起的hello程序。

图6-3 输入pstree指令

图6-4 输入fg指令

如上两张截图显示了在程序被挂起后输入pstree和fg指令的输出结果。fg指令让被挂起的hello程序恢复了运行。

图6-5 输入kill指令

输入kill指令发送-9消息给被挂起的hello进程,可以发现该进程被杀死了。

图6-6 输入Ctrl-C

运行hello时按Ctrl-C,会产生一个中断异常,从而内核产生信号SIGINT,父进程受到它后,向子进程发生SIGKILL来强制终止子进程hello并回收它。我们可以看到在输入了Ctrl-C指令后hello程序直接停止并退出了,此时再运行ps指令也看不到进程hello。

6.7本章小结

本章对进程的概念以及程序运行时进程的创建和回收进行了分析。以hello程序为例,观察了其在Shell中运行时的整个流程,我们还对进程运行时是如何处理产生的异常进行了举例分析。

7章 hello的存储管理

7.1 hello的存储器地址空间

结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。

逻辑地址是由一个段选择符加上一个指定段内相对地址的偏移量(Offset)组成的,表示为[段选择符:段内偏移量],而虚拟地址,其实就是如上逻辑地址的段内偏移Offset。

线性地址是平坦的统一地址空间。intel x86中,线性地址是由逻辑地址经过段页式转换得到的。

虚拟地址是CPU生成的用于访问主存的一个地址。虚拟地址在被送至内存前先要通过地址翻译被转换成适当的物理地址。

物理地址直接对应了计算机系统的主存中的单元。主存被组织成一个M个连续字节大小的单元组成的数组,每字节都有一个独立的物理地址。

当hello运行时,CPU先将要访问的虚拟地址发送到内存管理单元MMU进行地址翻译,再通过得到的物理地址到主存中访问具体的存储单元。

7.2 Intel逻辑地址到线性地址的变换-段式管理

在 Intel 平台下,逻辑地址是[selector:offset]的形式,selector可以是代码段或者数据段,offset是段内偏移。用selector去全局描述符表GDT里拿到段基址,然后加上段内偏移offset,就得到了线性地址。我们把这个过程称为段式内存管理。

总结来看:逻辑地址转换为线性地址的详细过程是:

1.先从段选择符selector中得到段描述符;

2.从段描述符中得到段基地址;

3.线性地址=上一步得到的段基地址+段内偏移(虚拟地址)

7.3 Hello的线性地址到物理地址的变换-页式管理

图7-1 基于页表的地址翻译流程

一个n位的虚拟地址(线性地址)包含两个部分:p位的虚拟页面偏移VPO和一个(n-p)位的虚拟页号VPN。MMU使用VPN来选择恰当的页表条目PTE,再将得到的PTE中的物理页号PPN和虚拟地址中的VPO串联起来,就得到了相应的物理地址。

7.4 TLB与四级页表支持下的VA到PA的变换

图7-2 TLB加速地址翻译

TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。如上图,用于组选择和行匹配的索引和标记字段是从虚拟地址中的VPN中提取出来的,TLB索引TLBI是由VPN的t个最低位组成,而TLB标记TLBT是由VPN中剩余的位组成。

图7-3 四级页表的工作流程

在四级页表的支持下,当TLB不命中时,虚拟地址中的VPN部分会被划分为四个片,每个片作为一个页表的偏移量。通过CR3寄存器获得L1页表的物理地址,VPN1提供到一个L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,依次类推。最后在L4页表中对应的PTE中取出PPN,与VPO连接,就得到了物理地址PA。

7.5 三级Cache支持下的物理内存访问

当CPU发出内存访问指令,MMU翻译得到物理地址后,这个物理地址被发送到L1缓存,缓存从物理地址中取出缓存偏移CO、缓存组索引CI以及缓存标记CT。若缓存中CI所指示的组有标记与CT匹配的条目且有效位为1,则Cache命中,在偏移量CO处的数据字节即为所需的内容。若Cache不命中,则需到低一级的Cache或主存中取出相应的块将其放入当前Cache中,然后再重新执行当前指令,就会转换为Cache命中的情况。

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核会为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制。

当fork从新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

在hello的进程运行execve函数时,它会在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序替代当前程序。加载并运行hello需要如下几个步骤:

1.删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。

2.映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。

3.映射共享区域: hello程序存在与共享对象(或目标)的链接,这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

4.设置程序计数器(PC):execve最后会设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。

7.8 缺页故障与缺页中断处理

在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。当CPU引用虚拟内存中的数据时,若该数据所在的页当前没有被缓存在DRAM中,就会引发缺页异常。缺页异常将调用内核中的缺页异常处理程序,该程序会选择一个牺牲页放回磁盘并用所需的页面覆盖这个牺牲页,同时修改页表中的相应信息。完成异常处理程序后,会返回到导致缺页的指令并重新执行该指令,现在不再会发生缺页的情况。

7.9动态存储分配管理

Printf会调用malloc,请简述动态内存管理的基本方法与策略。

当运行时需要额外虚拟内存时,一个十分方便,也具有很好的可移植性的方法是使用动态内存分配器。事实上,使用动态内存分配的最重要原因是,我们经常只有在实际运行程序时,才知道某些数据结构的大小。

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,指向堆的顶部。

分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块将一直保持空闲,直到它显式地被应用所分配。一个已分配的块将保持已分配状态,直到它被应用程序显式地释放或者被内存分配器隐式地释放。

C标准库中提供了一个名为malloc程序包的显式分配器,程序可以通过调用malloc函数来从堆中分配块。malloc函数的标准形式为:

void *malloc(size_t size);

此函数将返回一个指针,指向大小至少为size字节的内存块。像malloc这样的动态内存分配器可以通过使用mmap和munmap函数来显式地分配和释放堆内存。

7.10本章小结

本章从存储管理的角度分析了hello程序是如何通过虚拟内存运行在进程中。对于虚拟地址所涉及到的地址翻译、内存映射和动态内存分配等问题进行了详细讨论。

8章 hello的IO管理

8.1 Linux的IO设备管理方法

所有的I/ O 设备都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,即 Unix I/O,这可以让所有的输入和输出都能以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

通过Unix IO接口可以执行如下的操作:

1.打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息,而应用程序只需记住这个描述符。

打开文件所用到的是open函数,其标准形式为:

int open(char *filename,  int flags,  mode_t mode);

open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件,可以的打开方式有只读、只写和可读可写。flags参数也可以是多位掩码的或,给写操作提供一些额外的指示。mode参数指定了新文件的访问权限位。

2.改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为k。

3.读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m 字节的文件,当k>=m时执行读操作会触发一个称为EOF的条件,应用程序能检测到这个条件。

读文件使用read函数,其标准形式为:

ssize_t read(int fd, void *buf, size_t n);

read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,返回值0表示EOF,否则,返回值表示实际传送的字节数量。

类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。通过使用write函数完成写操作,其形式为:

ssize_t write(int fd,  const void *buf,  size_t n);

write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。

4.关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。当一个进程终止时,内核会自动关闭所有打开的文件并释放它们的内存资源。

进程通过调用close函数来关闭一个打开的文件,其形式为:

int close(int fd);

关闭一个已关闭的描述符fd会出错。

8.3 printf的实现分析

从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用int 0x80或syscall等。

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

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;

}

在形参列表里出现的省略号“…”是可变形参的一种写法,当传递参数的个数不确定时,就可以用这种方式来表示。但我们仍然需要一种方法,来让函数体可以知道具体调用时参数的个数。

va_list被定义为typedef char *va_list

因此printf函数中定义的arg就是一个字符指针

va_list arg = (va_list)((char*)(&fmt) + 4)中, (char*)(&fmt) + 4)表示的是“...”中的第一个参数。也就是说,当调用printf函数的时候,先是最右边的参数入栈。

fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的。

我们再看i = vsprintf(buf, fmt, arg);

vsprintf的作用是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。

最后printf函数调用write进行写操作,把buf中的i个元素的值写到终端。

8.4 getchar的实现分析

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

getchar由宏实现:#define getchar() getc(stdin)它有一个int型的返回值。当程序调用getchar()时,它会等待用户按键来输入字符。用户输入的字符被存放在缓冲区中,直到用户按了回车键,这时getchar()才会从stdio流中读入一个字符。这个过程可以看做为一个异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar函数调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

getchar函数的返回值是用户输入的字符的ASCII码,若出错返回-1,且将用户输入的字符回显到屏幕。若用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。若缓冲区当中还有未被读取的字符,getchar调用就不会等待用户按键,而是直接读取缓冲区中的字符,只有当缓冲区中的字符被读完后,才会重新等待用户按键。

8.5本章小结

(以下格式自行编排,编辑时删除)

本章概述了系统级IO的概念。针对Unix IO做了进一步的分析,了解了进程对于文件的各种操作。我们还以printf和getchar为例具体分析了其实现方法,了解了程序与用户交互的实现手段。

结论

一开始hello.c只是一段简单的代码,这时他与计算机的交集还几乎为0。hello.c不知道如何才能让CPU为自己运行,也看不懂被传输着的虚虚实实的地址,他甚至对自己所包含的<stdio.h>等引用也一知半解。

后来,编译器开始着手对hello.c进行加工。首先经过预处理器的修改,hello.c变成了hello.i。之前看不明白的include引用,现在都被详尽地展开添加进了代码当中。

紧接着编译器把hello.i翻译为了汇编语言代码,得到了新的hello.s文件。现在他可以从处理器的层面上下达模糊的操作指令,但他还说不清楚具体的操作地址,而且他还保持着自己文本文件的身份,不能与计算机的底层硬件进行沟通。

于是,汇编器进一步改造了hello.i,把他转换为了由0和1构成的二进制文件,是他具有了与硬件交互的能力。他现在跟最开始的hello.c相比已经面目全非,人们若是不通过其他工具已经难以读懂他的内容,但他始终牢记着自己的使命。在这个过程中,他还拥有了可重定向的目标文件的身份,这可以让他在接下来的过程中找到自己的数据、函数引用的具体位置。

最后,通过链接的过程,他终于成为了可执行的目标文件hello。他以虚拟地址的方式确定了自己每一行指令的地址和每一次跳转的目标。现在,它只需要等待被进程所加载,就可以开始自己的任务了。

终于,当Shell收到用户的指令,为hello程序fork了一个子进程,并通过execve将其加载。他现在正式拥有了自己的虚拟内存空间,在磁盘、内存和CPU中有了一席之地。处理器根据hello的命令执行着操作,并时刻注意着传来的消息,准备进行异常处理。当hello执行完毕后,他的内存被回收,他的进程就此结束,他在内存中的痕迹被干干净净地抹去。

就这样,hello完成了自己P2P——从程序到进程,以及020——从无到有再到了无痕迹的一生。

计算机系统的构造是如此精密而巧妙,hello只不过是一个简单到不能再简单的程序,但他仍然受到了跟其他千千万万的程序相同的对待。为了高效地实现每一个程序的功能,计算机系统可谓是煞费苦心,但从另一个方面来说,这些复杂的流程又运行地如此顺畅,以至于我们平常根本注意不到。hello的一生让我深刻体会到了计算机系统的设计是多么伟大和天才。

附件

hello.i:预处理文件,由源程序经预处理器翻译而来,删除了源程序中的注释,并将各种include引用和宏定义进行添加和替换操作。

hello.s:汇编语言文件,由预处理文件经编译器翻译而来,将高级语言程序翻译为汇编语言指令。

hello.o:可重定位目标文件,由汇编文件通过汇编器翻译而来,是一个二进制文件。包含了可重定位信息,供链接时使用。

参考文献

[1] Randal E. Bryant, David R. O’Hallaron. Computer Systems A Programmer’s Perspective. 北京:机械工业出版社..

[2] printf函数实现的深入剖析https://www.cnblogs.com/pianist/p/3315801.html

[3] getchar函数浅谈 https://blog.csdn.net/zhuangyongkang/article/details/38943863

[4] Linux内核中的逻辑地址、线性地址、虚拟地址和物理地址

         https://www.bilibili.com/read/cv8114824/

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值