HITCSAPP大作业:程序人生 hello‘s P2P

第1章 概述

1.1 Hello简介

程序员在文本编辑器中写下hello的C语言源代码,得到了hello的源文件hello.c。之后它被预处理、编译、汇编、链接后,得到了可执行文件hello。在终端运行hello,bash会替程序员运行hello,在shell中fork一个子进程并execve hello,随后hello被加载,正式开始运行。在运行过程中可以对其进行各种操作,包括使用Ctrl+C, Ctrl+Z等向其进程或其父进程传递各种信号。最后程序员在敲入最后一个字符后,程序终止,被父进程回收,彻底结束了它的一生。

1.2 环境与工具

列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。

软件环境:windows 10 x86-64,VMware16,Ubuntu18.04 64位。

硬件环境:基于x64的处理器;2.30GHZ,8G RAM 80+383.33GB固态硬盘。

开发工具:gcc, codeblocks, gdb, edb-debugger, objdump, HexEdit

1.3 中间结果

hello.c:hello程序的C语言源文件

hello.i:hello.c预处理后的文件

hello.s:hello程序的汇编语言文件

hello.o:hello程序经过as汇编后的可重定位文件

hello:hello程序经过链接得到的可执行文件

hello-elf.txt:hello.o文件的ELF格式文本文件

hello_o-objdump:hello.o文件的反汇编文本文件

hello-elf2.txt:hello可执行文件的ELF格式文本文件

hello-objdump.txt:hello可执行文件的反汇编文本文件

1.4 本章小结

初步介绍了在进行本次对hello程序的“一生”的探索需要的工具和基本流程

第2章 预处理

2.1 预处理的概念与作用

预处理是在文本文件编译之前进行的处理,在该阶段中,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。

预处理的主要作用如下:

1.将源文件中以”include”格式包含的文件复制到编译的源文件中

2.用实际值替换用”#define”定义的字符串

3.根据”#if”后面的条件决定源文件中要编译的代码

2.2 在Ubuntu下预处理的命令

                                                 图2.2.1 Ubuntu下使用gcc进行预处理

                                                      图2.2.2 预处理后产生的文件hello.i

2.3 Hello的预处理结果解析

首先展示一下hello.c源文件: 

                                                                图2.3.1 hello.c源文件

在经过预处理之后,得到的hello.i文件大小明显增加,添加了极多源文件中不包含的代码:

                                                     图2.3.2 hello.i文件(总行数到达了3118行)

在hello.i文件中,main()函数之前的代码主要是头文件stdio.h, unistd.h, stdlib.h的展开内容。值得注意的是,搜索得到的stdio.h文件中包含非常多的宏定义(#define),但是在hello.i文件中不存在任何的宏定义,这说明在预处理过程中,宏定义也将被cpp处理,或进行值之间的对应,或进行展开。同时,源文件中的注释也消失了。

                                                 图2.3.3 stdio.h文件中的宏定义,在cpp处理后全部展开

2.4 本章小结

本章简要介绍了预处理器的概念与作用,并对hello.i文件进行了分析。

3.1 编译的概念与作用

3.1.1编译的概念

编译是指编译器将已经预处理后的源文件“翻译”成由汇编语言所构成的以.s为后缀的文本文件。简言之,是ccl将高级语言翻译成汇编语言的一个过程。在一般情况下,“编译”常常指“编译”和“汇编”两个功能的集合体。

3.1.2编译的作用

在只讨论“编译”而不讨论“汇编”的前提下,编译有如下功能:

(1)词法分析:词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。

(2)语法分析:编译程序语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位。

(3)(可能存在的)代码优化:代码优化是指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。所谓等价,是指不改变程序的运行结果。所谓有效,主要指目标代码运行时间较短,以及占用的存储空间较小。这种变换称为优化。有些代码优化在编译阶段进行,有些代码优化则在汇编阶段进行。

       

3.2 在Ubuntu下编译的命令

3.2.1 Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.3.1 伪指令

在hello.s文件中,所有以“.”开头的行都是指导汇编器(as)和连接器(ld)工作的伪指令。它们只起到指导与提示的作用,不可执行、不占用ROM、也不会产生机器代码。

图3.3.1.1 部分伪指令

如图3.3.1.1所示,伪指令提示了变量sleepsecs为全局变量,值为2。

3.3.2 数据

(1)全局变量

如图3.3.1.1所示,全局变量(.global)sleepsecs位于数据段中(.data),4字节对齐,类型为变量(.type  sleepsecs, @object),大小为4(.size  sleepsecs, 4),初始化结果为2(.long 2)

(2)字符串常量

3.3.2.1 字符串常量

如图所示,hello.c中出现的两个字符串常量,均存储于只读数据段(.rodata)中。可以看出,中文的编码在编译阶段即完成,格式为UTF-8。

(3)局部变量

3.3.2.2 局部变量i

如图所示,在main函数的汇编指令中,上面的红色区域标识了跳转条件(若argc==3,跳转至.L2),在.L2中,给i赋初值0,则可以看出局部变量i的位置就是-4(%rbp),这说明局部变量会放置在栈中。

(4)常量

图3.3.2.3 常量10

在循环条件中出现的常量10,在编译后为立即数9(i<10,亦即i<=9),不存储于任何区域。

(5)数组

图3.3.2.4 指针数组argv[]

根据函数printf()的行为方式,容易知道其第一个参数存储于%rdi中,是格式字符串,而第二个参数即为argv[1],图中可以看到首先向%rax传递了-32(%rbp),此即为数组的首地址,之后对其+8是因为指针数据大小为8,argv[1]的位置是在首地址偏移8个字节的位置,argv[2]则偏移16字节。

3.3.3赋值

上述的数据部分中,已经包含了部分赋值内容。比如全局变量的赋值2,在编译阶段已经完成,一开始就将其定义成2,以供后续汇编。而字符串常量同样如此,直接存储于只读数据段中(常量本来也不需要赋值)。对于局部变量i,则是使用汇编指令进行的赋值操作(movl  $0, -4(%rbp))。

3.3.4类型转换

在hello.c中,全局变量被定义为int类型,但是赋了浮点数初值2.5。这里做了隐式的类型转换,将默认的double类型2.5转换成int类型时,会只取整数部分。所以可以看到全局变量sleepsecs一开始定义成了2,而非2.5。

3.3.5算术操作

在hello.s中,出现了三种算术操作,分别是addq, leaq, subq,他们的功能如下:

addq  S, D

DßD+S

leaq  S, D

Dß&S

subq  S, D

DßD-S

3.3.5.1 算术操作举例

如图所示,subq操作将栈帧向下生长32个字节,而leaq操作则是将%rip的值+.LC0进行了一个相对寻址,取出常量字符串并传递给printf()的第一个参数%rdi中。

3.3.6 关系操作

关系操作往往与循环条件、判断条件紧密相关。在hello.s文件中,出现了如下关系操作:cmpl, je, jmp, jle

Cmpl  S1, S2

比较S1, S2,基于S2-S1的结果设置条件码

Je  Label

相等跳转

Jmp  Label

直接跳转

Jle  Label

小于或等于时跳转

3.3.6.1 hello.s中的关系操作

如图所示,红色区域是if(argc!=3)的判断语句和其跳转,蓝色区域是跳转到循环判断的指令,而黄色区域则是满足循环条件i<10时的判断指令。

3.3.7 数组操作

在3.3.2 数据中已经明确展示了数组的操作方法,即直接对地址进行运算和操作,得以取出不同地址下的内容。具体例子在3.3.2 (5)中已经有所体现。

3.3.8 控制转移

控制转移是实现代码中逻辑跳转与分支的重要组成部分,且紧密依赖于关系操作。在hello.c中,出现了判断和循环两种最典型的控制转移。如图3.3.6.1所示,红色和蓝色虚线框所标识的代码即为if(argc!=3)的判断逻辑。如果argc==3,则跳转至L2准备进行循环。而黄色部分则是循环的判断区域,如果i<=9,则跳转到.L4(即循环体中)执行相应代码。

3.3.9 函数操作

以printf()函数为例,C语言函数体的进入和返回依靠的是call和ret指令,而参数传递则是通过一部分特殊的寄存器来实现的(如%rdi, %rsi等等),超出上限的参数将会在函数栈帧中开辟一部分特殊的区域用于存储。

在hello.s中,最明显的例子就是printf()函数:

图3.3.9.1 printf()函数

如图所示,在调用printf()函数之前(黄色虚线框),红色和蓝色虚线框中的代码进行了参数的传递。%rdi中存储了printf()函数的第一个参数:格式化字符串,而蓝色部分则是argv[1], argv[2]的数组内容传递,分别是第2、第3个参数%rsi, %rdx。而printf()的具体实现需要借助头文件 stdio.h,将会在链接过程中链接上。

3.4 本章小结

编译过程将.i文件翻译成了.s文件,使用汇编语言重构了高级语言文件。但此时仍然是文本文件,尚未形成目标文件,需要汇编器进一步处理。

第4章 汇编

4.1 汇编的概念与作用

汇编的概念:指使用汇编器(as)将.s文件翻译成机器语言二进制可重定位文件(.o文件)的过程。

汇编的作用:在这个过程中,文件由文本文件首次被翻译成了机器语言的二级制文件,同时是可重定向的,目的是为了之后链接器进行链接,生成可执行文件。

4.2 在Ubuntu下汇编的命令

4.2.1 Ubuntu下的汇编命令

4.3 可重定位目标elf格式

使用readelf -a hello.o >hello-elf.txt命令生成hello.o的ELF格式文件:

4.3.1 hello.o文件的ELF格式

4.3.1 ELF头

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

4.3.1.1 hello.o文件ELF头内容

我们可以从ELF头看到许多有关hello.o文件的重要信息。比如本hello.o文件类型为ELF64,是小端序程序,类别为可重定位文件。节头起始位置为文件中第1104字节,节头大小为64字节等等。

4.3.2 节头部表(节头)

  在ELF格式程序中,不同节的位置和大小是由节头部表描述的。在目标文件中,每一个节都在节头部表中有一个条目。

4.3.2.1 hello.o文件的节头部表

在节头部表中可以看到程序中所有节的位置和大小信息。

4.3.3 重定位项目分析:

  使用readelf -r hello.o指令读取文件的可重定位信息如下:

4.3.3 读取hello.o文件的重定位信息

考察.rela.text节的内容,其中,偏移量确定需要重定位的代码在.text中的位置,信息是要重定位的符号在符号表中的序号,类型则是重定位的模式,在链接中会体现出不同的行为方式。在符号名称+加数中,就展示了符号的名称和其在具体链接的过程中需要加入计算的偏移量(Addend)。

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

关于.rela.eh_frame,在网上查询后,发现该节应该是提供重定位eh_frame节信息的,而eh_frame节的作用与维护程序和堆栈展开有关系,书上没有讲述。

  4.3.4 符号表

4.3.4.1 hello.o文件的符号表

    符号表包含了这个可重定位文件定义和引用的符号的信息,其中包括了符号的值、大小、类型、bending(本地或全局)等等。

4.4 Hello.o的结果解析

  使用obidump得到hello.o的反汇编文件内容如下:

图4.4.1 hello.o文件的反汇编代码

此时的反汇编代码已经和hello.s文件有了非常大的差别。首先所有以“.”开头的伪指令全部消失了;在hello.s文件中需要限制字数的一些汇编操作如movq, addq等,在hello.o文件反汇编中全部变成了最基本的操作mov, add;有关分支的跳转不再是以.Label形式体现,而是直接用操作数来表示;调用函数的指令call 的目标也不再是函数名,而同样使用了操作数来表示;同时需要在链接过程中加入的内容全部以00占位(如图中红色虚线框区域所示)。

有关机器语言的内容:机器语言是由一系列01机器指令序列构成的机器代码,可以由汇编语言翻译而来,是最底层的、可以直接被机器识别的语言。

4.5 本章小结

汇编器将.s文件重新翻译成可重定位文件(.o文件),并为之后的链接做了充足的准备,提供了链接时需要的各种信息,包括需要重定位的符号、位置等等。此时文件终于从文本文件变成了二进制文件。

5章 链接

5.1 链接的概念与作用

链接的概念:链接是使用链接器(ld)将一系列可重定位目标文件和(可能存在的)命令行参数作为输入,并生成一个完全链接的、可以加载和运行的可执行目标文件。链接可以是在生成程序之前进行,也可以在程序运行的时候进行。

链接的作用:链接使得分离编译成为可能。大大简化了大型程序的编译和调试过程。有了链接,“库”的概念才得以实现,使得编程更加轻松。

5.2 在Ubuntu下链接的命令

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/x86_64-linux-gnu/crtn.o/usr/lib/x86_64-linux-gnu/libc.so hello.o -o hello

crt1.o包含程序入口函数

crti.o和crtn.o包含函数_init()和_fini()

crt1.o, ctri.o, ctrn.o依赖于库libc.so

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

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

图5.3.1 可执行文件hello的节头部表(部分)

如图所示,从可执行文件hello的节头部表中,可以得到hello文件的各节的基本信息。以.data节为例:

图5.3.2 可执行文件hello的.data节基本信息

可以看到,.data的节序号为20,大小为0x08字节;PROGBITS是节类型,含义是“程序数据”,与之有相同类型的有.text, .rodata等;该节在文件中的起始地址是0x601040;旗标为WA(write, alloc);页偏移量为0x1040(猜测页起始地址为0x600000);4字节对齐。

因为hello是可执行文件,不需要再重定位,所以在hello.o的ELF格式中看到的.rela.data等可重定位节在节头部表中找不到了。

ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片被映射到连续的内存段。程序头部表描述了这种映射关系。如下图所示:

图5.3.3 可执行文件hello的程序头和段节信息

以read-only code segment(第一个LOAD,只读代码段)为例,该段起始于地址0x400000,无偏移量,大小为0x770字节,有读/执行权限。根据段节信息还可以看到这个段包含了节:.text, .rodata, .interp等等。

5.4 hello的虚拟地址空间

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

图5.4.1 使用edb加载hello

再以只读代码段为例,在5.3中,该段的起始地址为0x400000,无偏移量,则其被映射到hello的虚拟地址空间0x400000处。

图5.4.2 代码段起始地址与内容(左下角标蓝部分)

5.5 链接的重定位过程分析

图5.5.1 可执行文件hello的反汇编代码

可以看到,可执行文件hello的反汇编代码明显比hello.o的反汇编代码多了很多。在5.2中提到了crt1.o, crti.o, ctrn.o几个文件对于形成一个可执行文件是必须的,提供了一些函数(.init, .fini等),同时也链接了.c文件中头文件里使用到的函数代码(puts, printf等)。在链接过程中,链接器会将输入的多个可重定位文件中的节按照属性合并,并将这些合并后的节按照其访问属性(如只读、读/写等)分为不同的段(Segment),输出时称之为段节(如图5.3.3所示)。

下面考察重定位情况,以printf()函数为例,首先我们需要找到它的重定位信息,在图4.3.3中出现过:

图5.5.2 printf()函数的重定位信息

根据类型,它的重定位是PC相对寻址的。偏移量指明,其在.text节中出现的位置位于节开头后0x56处,加数为-4

图5.5.3 printf函数的重定位信息在hello.o的反汇编中的体现(光标处)

这里值得一提的是,该偏移值是相对hello.o中.text的偏移值,但是在链接并最终生成hello后,.text由于被合并,发生了变化(比如链接时加入的ctr1.o, ctri.o等文件),但是链接器在链接这些文件时有一个先后顺序,这种最终呈现出来的变化不会影响在链接时使用偏移值的正确性。但是我们在之后进行考察时需要注意。

在hello的反汇编文件中,我们找到了main函数的起始位置为0x4005b2,那么根据printf()函数的重定位偏移值时0x56,得到重定位位置为0x400608:

图5.5.4 需要重定位的位置(光标处)

由于是相对跳转,那么占位符00 00 00 00应该被替换成:

ADDR(printf@plt) + addend(-4) - 0x400608

而ADDR(printf@plt)的地址是0x4004c0,经过计算我们可以得到这个值的十进制是-332,补码转换为fffffeb4,注意小端法写入其中即可完成重定位。

5.6 hello的执行流程

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

使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。

使用edb调试,设置断点为0x400500(.text段起始位置),如果以stepover形式执行,会进入如下函数序列:

0x00007ff0948ddea0 <ld-2.27.so!_dl_start+0>

0x00007ff0948ec630 <ld-2.27.so!_dl_init+0>

0x00007fc0b2231ab0 <libc-2.27.so!__libc_start_main+0>

0x4004c0 = 0x00000000004004c0 <hello!printf@plt+0>

0x4004f0 = 0x00000000004004f0 <hello!sleep@plt+0>

(printf()和sleep()会执行10次,循环往复的进入)

0x4004d0 = 0x00000000004004d0 <hello!getchar@plt+0>

0x7fc0b2253120 = 0x00007fc0b2253120 <libc-2.27.so!exit+0>

如果以stepin形式执行,会进入极其复杂的函数序列,部分展示如下:

0x00007ff0948ddea0 <ld-2.27.so!_dl_start+0>

0x00007ff0948ec630 <ld-2.27.so!_dl_init+0>

0x00007fb6f0382ab0 <libc-2.27.so!__libc_start_main+0>

0x7fb6f03a4430 = 0x00007fb6f03a4430 <libc-2.27.so!__cxa_atexit+0>

0x7fb6f03a4220 = 0x00007fb6f03a4220 <libc-2.27.so!__new_exitfn+0>

rbp = 0x0000000000400540 <hello!__libc_csu_init+0>

0x400488 hello!_init

0x7f68290c9c10 = 0x00007f68290c9c10 <libc-2.27.so!_setjmp+0>

0x7f68290c9b70 = 0x00007f68290c9b70 <libc-2.27.so!__sigsetjmp+0>

0x7f68290c9bd0 = 0x00007f68290c9bd0 <libc-2.27.so!__sigjmp_save+0>

以上是进入main()函数之前执行的函数序列,接下来尝试在main()函数内部依然使用stepin方式进入函数体,若不使用则与stepover结果一致。

0x00000000004004c0 <hello!printf@plt+0>

0x00007fba3909d680 <ld-2.27.so!_dl_runtime_resolve_xsave+0>

0x7fba39095df0 = 0x00007fba39095df0 <ld-2.27.so!_dl_fixup+0>

0x7fba390910b0 = 0x00007fba390910b0 <ld-2.27.so!_dl_lookup_symbol_x+0>

0x7fba39090240 = 0x00007fba39090240 <ld-2.27.so!do_lookup_x+0>

0x7fba39097700 = 0x00007fba39097700 <ld-2.27.so!_dl_name_match_p+0>

0x7fba390a3360 = 0x00007fba390a3360 <ld-2.27.so!strcmp+0>

(进行了非常多的字符串比较,包括“printf“与”printf“比较,不再赘述)

0x7fba38cf0390 = 0x00007fba38cf0390 <libc-2.27.so!vfprintf+0>

0x4004f0 = 0x00000000004004f0 <hello!sleep@plt+0>

0x4004a0 hello!.plt

0x7fba39095df0 = 0x00007fba39095df0 <ld-2.27.so!_dl_fixup+0>

0x7fba38d79990 = 0x00007fba38d79990 <libc-2.27.so!nanosleep+0>

(在进入sleep函数后,由于要循环10次,出现了非常多的子函数,实在难以一一列出)

0x00000000004004d0 <hello!getchar@plt+0>

0x00007fba38cd8120 <libc-2.27.so!exit+0>

0x00007fba38cd7ed0 <libc-2.27.so!__run_exit_handlers+0>

0x00007fba38cd88ce <libc-2.27.so!__call_tls_dtors+94>

0x00007fba390969a0 <ld-2.27.so!_dl_fini+0>

0x00007fba390870e0 <ld-2.27.so!rtld_lock_default_lock_recursive+0>

0x7fba3909df38 = 0x00007fba3909df38 <ld-2.27.so!_dl_sort_maps>

0x7fba390a5a20 = 0x00007fba390a5a20 <ld-2.27.so!memset+0>

(后续好像在做内存的管理,出现了大量子函数,省略)

5.7 Hello的动态链接分析

共享库以.so结尾,在生成hello程序的过程中,可以看到链接了ld-linux-x86-64.so这个共享库。共享库的编译需要使用可以加载但是无需重定位的代码,即位置无关代码。如图4.2.1中所示,在使用汇编器汇编生成hello.o时,使用了-fno-PIC指令,这就是生成位置无关代码的指示。在引用PIC数据时,编译器利用了如下特性:无论在内存中的何处加载一个目标模块,数据段和代码段的距离总是保持不变的。代码段中的任何指令和数据段中任何变量之间的距离都是一个运行时常量,与绝对内存位置无关。而在进行PIC函数调用的时候,编译器没有办法预测这个函数的运行时地址,因为共享库可以被加载到内存的任意位置。也不允许重定位,因为这会使链接器修改调用模块的代码段。GNU编译系统使用了一种叫做延迟绑定的技术。

首先需要查看.GOT的内容,在未运行时使用readelf指令读取该段信息如下:

5.7.1 .GOT内容

再查看可执行文件hello的反汇编代码,得到如下信息:

5.7.2 0x00601030对应函数exit@plt

再查看.PLT的内容:

5.7.3 .PLT内容(留意0x004004e0 第七个字节处)

可以看到,在未运行时,GOT中对应的地址其实就是PLT中的下一条指令(4004e6)

在edb中如下显示:

5.7.4 未运行dl_init时,GOT内容(左下角标蓝)

在运行完dl_init后,GOT发生如下变化:

5.7.5 运行后GOT内容的变化(重定位条目地址,动态链接器地址被确定,601000后8字节、601010前8字节发生变化)

重定位条目(reloc entries)和动态链接器(dynamic linker)被确定下来,在之后的函数调用中,PLT和GOT协同工作,在调用该函数的时候就可以通过一系列跳转,进行重定位和动态链接,实现延迟绑定功能。

5.8 本章小结

通过链接,hello程序得以链接上程序启动时真正需要的一系列工作内容,包括共享库等等。在链接后,hello程序能够正式运行,至此,从hello.c的文本文件,经过编译、汇编、链接,生成了可执行文件。

6章 hello进程管理

6.1 进程的概念与作用

进程的概念:进程的经典定义就是一个执行中的程序的实例

进程的作用:进程为操作者提供了一个假象,好像程序是系统中目前运行的唯一程序一样。每一个进程看起来都在唯一且私有的地址空间运行,并且有独立的逻辑控制流。这大大方便了程序的维护、内存的管理等等。

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

Shell是一个交互级的应用程序,它代表用户运行其他程序。Shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行,求值步骤解析命令行,并代表用户执行程序。

6.3 Hello的fork进程创建过程

在终端键入:./hello 1190202211 孙宇辰

Shell-bash读取该命令行,首先检查是否有内置命令(如quit, jobs等),发现并非内置命令后,拆分命令行并存入argv[]中。之后shell进程fork一个子进程,得到的子进程有和shell当前完全一致的上下文、用户栈等等信息(完全一致的用户虚拟地址空间),并且允许子进程使用父进程打开的一切文件。其与父进程主要的不同是其PID不同。

6.4 Hello的execve过程

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

Shell在fork子进程后,会在子进程中使用argv[]和全局变量environ作为execve的参数,执行用户输入的程序(hello)。execve函数保留内核虚拟内存,但是会删除当前子进程的全部用户虚拟内存中的内容,并根据参数构建新的用户及虚拟内存。Execve函数创建的虚拟内存空间如下:

图6.4.1 execve执行后创建的用户级虚拟内存

6.5 Hello的进程执行

进程上下文信息:内核为已经加载并运行的进程hello维持一个上下文,上下文由一些对象的值组成,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

上下文切换:进程与进程之间有时存在抢占关系,内核中的调度器来调度当前暂时运行哪一个进程。这种切换一般是在出现中断时,内核将该进程由用户模式切换到内核模式的时候进行的(比如hello中对sleep函数的调用,就会使该进程从用户模式切换到内核模式)。在内核模式中对该进程进行适当的处理时,调度器就会执行上下文切换,重新开始另一个进程,直到原进程再次从内核模式切换到用户模式,优惠进行上下文切换。而一个进程执行它的控制流的一部分的每一时间段就叫做时间片。这样的行为使得每一个进程的逻辑控制流都好像都是独立而且连续的,但是实际上,从“时间片”这个命名就可以看出,这是不连续的。

6.6 hello的异常与信号处理

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

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

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

(1)正常运行至结束

图6.6.1 正常运行结束,该进程已被回收

(2)使用Crtl+Z

Ctrl+Z会挂起前台进程,此时hello会被挂起,但是没有终止,更没有回收

图6.6.2 Ctrl+Z挂起进程

使用ps指令看到该进程依然存在,使用jobs发现其作业序号为1,使用fg将上一个进程调度到前台,可以发现它能够继续执行。

(3)使用Crtl+C

图6.6.3 Ctrl+C终止进程

使用Ctrl+C后,hello被终止并回收

(4)乱按

图6.6.4 乱按

出现了很有趣的现象,乱按时字符串会进入缓冲区,在getchar时被读入。但是一旦输入回车,就会在读入getchar之后当作命令行参数进行解读。

6.7本章小结

Shell作为交互级应用程序,替用户执行hello,并在运行过程中处理各种可能出现的异常,保证进程、系统按照合理的规则健康的运行。

7章 hello的存储管理

7.1 hello的存储器地址空间

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

在存储器中,以字节为单位存储信息。为了正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址。物理地址是绝对地址,真实地反映了信息的存储位置。hello程序的所有指令、数据都一定存储在某个物理地址下。

在有地址变换功能的计算机中,访内指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。在某种程度上,偏移量可以被理解为逻辑地址的一种体现。比如在未链接的hello.s中,可以看到部分跳转指令的目的信息是通过“符号+偏移量”来表示的。

线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。在Linux中,可以认为线性地址就是虚拟地址。

虚拟地址则是操作系统对存储器和I/O设备的抽象,虚拟地址系统为每一个进程都提供一个看起来独立的地址空间,形成每一个进程都运行在独立的地址空间的假象。这样做能够大大方便内存管理、简化链接等。在hello中,我们看到的.text段的起始地址是0x400000,这是一个虚拟地址,需要在MMU中转化成为物理地址。虚拟内存系统提供了这种从虚拟地址到物理地址的映射关系。

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

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

分段提供了隔绝各个代码段/数据段和堆栈段的机制,因此多个任务可以运行在同一处理器上而不互相干扰。在intel中,一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。索引号用来查找对应的段描述符,通过段描述符,可以来寻找某个段的起始线性地址。在Intel中,全局的段描述符被存储在全局段描述符表,而专属于每个进程的段描述符被存储在局部段描述表。段选择符中存在着字段用于描述到底该从哪个段描述符表中寻找段信息。将逻辑地址转换为线性地址,只需要首先通过索引号找到段的基址,再加上偏移量,即可得到线性地址。

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

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

线性地址到物理地址的变换,实际上就是虚拟地址到物理地址的转换。为了方便进行虚拟内存的管理,系统将虚拟内存划分为了大小是4K字节的连续存储块,并称之为“虚拟页”,与之对应的物理地址也会被划分成这样的页,称为物理页。虚拟页和物理页之间存在映射关系。对于一个进程来讲,这种对应关系存储在页表中。通过索引,可以在页表中找到对应的物理页号,经过转换变成物理地址。

对于一个给定的线性地址(虚拟地址),首先将虚拟地址拆分为两部分,一部分是页号、一部分是页内偏移。页内偏移共12位,表明了信息起始于页表的哪个位置。通过虚拟页号作为索引,在页表中找到对应的页表条目,得到物理页号。物理页号+页内偏移即可得到物理地址。

在实际应用中,还有通过TLB加速PTE读取、多级页表节省空间等操作。

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

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

页表条目存储在主存中,虽然可以通过L1cache进行存取,但是时间仍然不令人满意。为了解决这个问题,设计出了TLB。TLB是页表条目的高速缓存,实际上比L1cache存取速度还要快。TLB是全相联的高速缓存,对于给定的虚拟页号,系统会根据TLB情况(组数等)将其解读,过程类似cache(组号+索引)。如果缓存未命中,则需要使用传统方法,在页表中找到PTE。而如果只用一个页表来存储PTE,由于虚拟地址空间非常大,会造成页表体积过于巨大,占据大量存储空间。

为了解决这个问题,多级页表的概念被提出。在多级页表的组织架构中,高级页表存储指向低级页表的指针,层层递进,直到最底层页表,其存储着物理页号。这样使得每一级页表的大小相对于一个单独的庞大页表来讲,大大缩小。同时,若在高级页表中不存在低级页表的指针,那么其对应的所有空间都会是空闲的,不会占据太多空间,这大大节约了内存。

在四级页表的组织下,一个虚拟地址(以48位举例)拆去低位的页偏移(12位),留下的36位每9位对应一级页表。比如最高9位是一级页表的索引,内容指向二级页表,后9位就是二级页表的索引,以此类推,逐步找到物理页号。

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

高速缓存区被组织成了一个数组结构。一个容量为C的cache由S组缓存组成,每组有E行,每行有B个字节的数据块,C=S*E*B。对于一个给定的物理地址,其低b位(2^b=B)是块偏移,然后s位(2^s=S)是组索引,剩余位置是tag,用于指示在某组中是否存在着确切的对应位置。若物理地址按照这种逻辑进行解读后,在cache中存在着具体的内容,则缓存命中,从这层缓存中取出数据;若没有,则需要进行一次加载/驱逐操作,从底层缓存中取出数据。若底层数据还没有,则继续向底层寻找,直到找到为止。

7.6 hello进程fork时的内存映射

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

当fork函数被调用时,内核为新进程创建了各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct,区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每一个区域结构都标记为私有的写时复制。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任意一个后来进行写操作的时候,写时复制机制就会创建新页面。

7.7 hello进程execve时的内存映射

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

Execve函数在运行hello时,执行了以下几个步骤:

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

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

(3)映射共享区域。如果hello的可执行文件与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

(4)设置程序计数器PC。

在下一次调度这个进程的时候,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

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

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

假设MMU在试图翻译某个虚拟地址A的时候,触发了一个缺页。这个异常将导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:

(1)虚拟地址是否合法?若指令不合法,那么缺页处理程序就触发一个段错误,从而终止这个进程。

(2)试图进行的内存访问是否合法?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程

(3)此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU,这次MMU就能正常地翻译A,而不会再产生缺页中断了。

7.9动态存储分配管理

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

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

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视作一组大小不同的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式的保留为供应用程序使用。空闲块可用来分配。

分配器有两种基本风格(显示分配器、隐式分配器),两种风格都要求应用显式的分配块。它们的不同之处在于由哪个实体来负责释放已经分配的块。显示分配器要求应用显式的释放任何已分配的块,隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。

对于显式内存分配器,也有着不同的具体实现方法,主要包括隐式空闲链表和显式空闲链表两种。这两种分配器的区别在于如何组织空闲块。隐式空闲链表通过边界标记记录块大小来间接的指示下一块的位置,而显式空闲链表则在空闲块中存储着一个指向下一个空闲块的指针。

对于空闲块的分配方式也有不同,包括首次适配、下一次适配、最佳适配和分离适配。不同适配方案的吞吐率和内存利用率各有不同。

对于空闲块,还有拆分和合并的策略来节约空间。

7.10本章小结

本章主要介绍了虚拟内存这一强大的抽象。计算机系统通过虚拟内存,使得每一个进程都好像运行在独立且唯一的地址空间内。这对内存管理、内存映射都有很大帮助。在虚拟内存的概念下,又介绍了虚拟内存的组织形式、虚拟地址和物理地址的对应关系、空闲内存空间的分配等等细节实现。

8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

一个Linux文件就是一个m字节的序列,所有的I/O设备(例如网络、磁盘和终端)都被模型化成文件,而所有的输入和输出都被当作对应文件的读和写来执行。使用这种将设备优雅地映射为文件的方式,允许Linux内核引用出一个简单、低级的应用接口,称作Unix IO,这使得所有的输入和输出都能以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

Unix IO令所有输入和输出都能以一种统一且一致的方式来执行:

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

(2)Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符为0)、标准输出(文件描述符为1)和标准错误(文件描述符为2)。

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

(4)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会触发一个EOF的条件,应用程序能够检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k.

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

其主要函数如下:

int open(char *filename, int flags, mode_t mode)//将文件名转换为一个文件描述符,并返回它。

int close(int fd)//按照文件描述符关闭一个文件,若成功返回则为0,若出错则返回-1。

ssize_t read(int fd, void *buf, size_t n)//在文件中读取n个字节并置于buf中,若成功则返回读的字节数,若EOF则为0,若出错则为-1

ssize_t write(int fd, const void *buf, sizt_t m)//向文件中写入n各字节,若成功则为写的字节数,若出错则返回-1.

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;
    }

其中,形参“…”表示可变形参,当传入的函数参数个数不确定的时候,可以使用这种形参。函数内变量arg则是获取可变形参列表的首地址。

printf函数在获取了格式字符串和可变形参列表后,以其作为参数传递给了函数vsprintf(buf,fmt,arg):

int vsprintf(char *buf, const char *fmt, va_list args)

   {

    char* p;

    char tmp[256];

    va_list p_next_arg = args;

  

    for (p=buf;*fmt;fmt++) {

    if (*fmt != '%') {

    *p++ = *fmt;

    continue;

    }

  

    fmt++;

  

    switch (*fmt) {

    case 'x':

    itoa(tmp, *((int*)p_next_arg));

    strcpy(p, tmp);

    p_next_arg += 4;

    p += strlen(tmp);

    break;

    case 's':

    break;

    default:

    break;

    }

    }

  

    return (p - buf);

   }

vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出,即构建出想要打印在屏幕上的字符串,并且返回这个字符串的字节数。在hello中,这个函数会构建出“hello 1190202211 孙宇辰”。

Printf函数接下来调用了write函数:

  write:
     mov eax, _NR_write
     mov ebx, [esp + 4]
     mov ecx, [esp + 8]
     int INT_VECTOR_SYS_CALL

除了传递参数,write汇编代码的最后一行:int INT_VECTOR_SYS_CALL是通过系统调用syscall,其实现如下:

  sys_call:

     call save

  

     push dword [p_proc_ready]

  

     sti

  

     push ecx

     push ebx

     call [sys_call_table + eax * 4]

     add esp, 4 * 3

  

     mov [esi + EAXREG - P_STACKBASE], eax

  

     cli

  

     ret

其中,call save是为了保存中断前进程的状态,ecx中是要打印出的元素个数,ebx中的是要打印的buf字符数组中的第一个元素,这个函数的功能就是不断的打印出字符,直到遇到:'\0'。

最后通过字符显示驱动子程序将字符的ASCII码存到显示vram(存储每一个点的RGB颜色信息)中,显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。在屏幕上就显示出了想要打印的字符串。

8.4 getchar的实现分析

异步异常-键盘中断的处理:当用户从键盘输入信息时,会引起中断,系统会切换进程并键盘中断处理子程序,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。同时,结束系统调用,回到原来被抢占的进程,getchar返回第一个字符。

8.5本章小结

本章内容介绍了文件的概念、I/O设备的抽象与实现,同时以printf的具体实现为例,展示了一个字符串是如何最终在系统调用和用户端的共同作用下输出到屏幕上的。

结论

预处理:将.c文件处理成加入了部分信息的.i文件,包括头文件和宏定义

编译:使用编译器将.i文件翻译成由汇编语言描述的.s文件

汇编:使用汇编器将.s文件转化成由机器代码构成的可重定位目标文件.o

链接:通过符号解析、重定位和一系列静态库、动态库的链接,形成真正可以被加载的可执行文件

运行:在shell中输入hello和命令行参数,shell将替代用户运行hello程序,fork一个子进程并在子进程中execve hello程序

异常:在运行过程中,使用ctrl+c/ctrl+z向进程发送信号,进程会执行信号处理程序,进行上下文切换,调用对应程序进行对信号的处理。

内存管理:在加载过程中,虚拟内存系统为hello提供了一套独立的虚拟内存空间。通过MMU可以将虚拟地址转化为物理地址,在保证各个进程互不干扰又“看似独立运行”的背景下完成任务

程序终止:在完成getchar()函数后,程序退出,进程终止。等待父进程对其进行回收,至此,结束了hello的一生

感想:hello程序是每一个学习编程的人第一个接触到的程序。在之后的编程学习中,我们学习了很多比hello程序复杂的其他内容。但是,通过计算机系统的学习,我才第一次认识到hello程序的生命周期全过程是什么样子的,也震撼于设计计算机系统——这个可能是人类历史上最复杂的系统所需要的努力与智慧。我认为实现复杂系统的灵魂在于设计一套行之有效的处理流程,并将各个功能尽量原子化,使其单个部件简单基础,但是易于整合,拼凑出一个功能齐全复杂的大系统。hello程序在计算机中运行需要很多功能和部件协调统一,但是本质上,这不过是硬件里的高低电平、逻辑上的01序列编织而成的世界,这怎能不令人感叹。

参考文献

[1]  [转]printf的深入剖析:https://www.cnblogs.com/pianist/p/3315801.html

[2] 逻辑地址 线性地址 虚拟地址 物理地址关系:https://blog.csdn.net/icandoit_2014/article/details/87897495

[3]  RANDAL E. BRYANT, DAVID R. O’HALLARON 深入理解计算机系统原书第三版[M]. 北京:机械工业出版社,2016.7

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值