程序人生-Hello’s P2P

 

Hello,一个十分简单的程序,可以说是几乎全世界的程序员编写的第一个程序,我们一行一行地对着教程缓慢地输入Hello的几行代码,点击运行,惊喜地看到屏幕中输出的“Hello,World!”,然后就迅速地爱上了其他程序,却又不再回头,哪怕再多观望它一眼。然而,可以这么说,在这个简单的Hello中,囊括一个程序运行的所有过程,蕴含着无数计算机科学家的思想精华。从它的诞生再到它的逝去,它经历了每一个程序都会经历的一切:从预处理再到编译,又从编译到汇编,再从汇编到链接……在这个程序的背后,是操作系统和硬件的紧密配合,利用巧妙的抽象将一个复杂庞大的过程简化为一个最基础的程序。

故本文从这个Hello程序出发,从计算机系统层面上的各个方面去游览它的生命周期,触摸程序在计算机当中的脉搏,感受它平凡却又精彩的一生。

关键词:计算机系统;Hello程序;Linux;                           

 

第1章 概述... - 5 -

1.1 Hello简介... - 5 -

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

1.3 中间结果... - 6 -

1.4 本章小结... - 7 -

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

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

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

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

2.4 本章小结... - 9 -

第3章 编译... - 10 -

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

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

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

3.4 本章小结... - 14 -

第4章 汇编... - 16 -

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

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

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

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

4.5 本章小结... - 24 -

第5章 链接... - 26 -

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

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

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

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

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

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

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

5.8 本章小结... - 40 -

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

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

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

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

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

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

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

6.7本章小结... - 51 -

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

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

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

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

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

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

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

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

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

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

7.10本章小结... - 60 -

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

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

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

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

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

8.5本章小结... - 65 -

结论... - 65 -

附件... - 67 -

参考文献... - 68 -

(注:截图懒得转格式了 如果有需要的话可以私聊)

第1章 概述

1.1 Hello简介

      1. P2P的过程

P2P,即From Program to Process,就是指Hello从一个程序变成一个进程的过程,当我们通过高级语言(C)将Hello程序的代码敲进电脑并保存为hello.c时,这就创建了一个Hello程序。接着再通过GNU编译系统自带的gcc驱动程序,它会将我们的程序通过cpp预处理器将hello.c翻译成一个ASCII码的中间文件hello.i,再通过ccl编译器,将hello.i编译成ASCII的汇编语言文件hello.s,之后再通过as汇编器,将hello.s翻译成一个可重定位目标文件hello.o,最后经过ld链接器,将hello.o和一些系统的目标文件最终创建一个可执行目标文件hello,此时可以说,Hello,一个完美的生命就诞生了。

然而,想要到进程的过程还没那么简单,我们打开Shell-bash中输入./hello,而由于hello并不是一个Shell的内置命令,它将其识别为一个可执行目标文件,进而为这个程序创建一个子进程Process(fork),并为它execve、mmap等,使得它这个程序能够在这个进程中“驰骋“,而这就是Process的产生过程,亦即实现了P2P:From Program to Process。

      1. 020的过程

020,即From Zero to Zero,壳shell为hello通过fork生成子进程,再在子进程中用execve调用加载器加载hello程序,通过内存映射创建虚拟内存空间,将数据加载到虚拟内存空间中去,而内核又为程序分配时间片执行逻辑控制流,直至程序结束。此时hello所在的子进程会向父进程发送SIGCHLD信号,而Shell作为父进程回收该进程,清空该程序所占的空间,删除有关的数据结构,抹去所有痕迹。这就实现了020:From Zero to Zero。

1.2 环境与工具

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

1.2.1 硬件环境

      系统类型:X64

      CPU:12th Gen Intel(R) Core(TM) i7-12700H   2.30 GHz

      RAM:16.0 GB

      HHD:476.92 GB

图 1 CPU-Z截图

1.2.2 软件环境

Window11 64位、Vmware 16、Ubuntu 22.04 LTS 64位

1.2.3 开发与调试工具

GCC、Codeblocks、vim、gcb

1.3 中间结果

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

1.   hello.c:源代码文件。

2.   hello.i:预处理后的文本文件。

3.   hello.s:编译后的汇编文件。

4.   hello.o:汇编后的可重定位文件。

5.   hello:链接后的可执行文件。

6.   hello.o.txt:hello.o反汇编得到的文本文件。

7.   hello.txt:hello反汇编得到的文本文件。

1.4 本章小结

       本章介绍了Hello的P2P过程、020过程,详细介绍了完成本论文的所用的软件环境、硬件环境、开发与调试工具,并罗列出了完成本论文的中间结果文件及其作用。

第2章 预处理

2.1 预处理的概念与作用

概念:预处理(preprocessing)指的是预处理器(cpp)根据以字符#开头的命令(如#include#define#pragma等),修改原始的C程序,最后生成.i文本文件的过程。

作用:

  1. 预处理会将所有的#define删除,并且展开所有的宏定义。
  2. 处理所有的条件预编译指令,比如#if、#ifdef、#elif、#else、#endif等。
  3. 处理#include预编译指令,将被包含的文件直接插入到预编译指令的位置。
  4. 删除所有的注释。
  5. 添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号。
  6. 保留所有的#pragma编译器指令

合理使用预处理功能编写的程序便于阅读、修改、移植和调试,也有利于模块化程序设计。

2.2在Ubuntu下预处理的命令

预处理命令:gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i

注意这里-m64是64位程序编译,-no-pie是为了防止生成地址无关程序,-fno-PIC是为了防止生成位置无关代码使文件变为动态对象从而使得后面无法通过静态链接生成可执行文件。(后面这边部分就不会再重复了)(附:其实刚开始做时忘记加这些,结果中间从编译之后生成的都有很多不同,比如汇编代码中的函数会跟上@PLT,中间会出现32位寄存器,在查看ELF头等的类型时是动态的,还有无法静态链接等)

程序人生-Hello’s P2P_第1张图片

图 2 预处理命令

程序人生-Hello’s P2P_第2张图片

图 3 预处理文件

输入命令后,文件夹出现hello.i文件

2.3 Hello的预处理结果解析

分析hello.i可以发现:程序中的注释被删除,而#include所包含的头文件被换成相应的代码;hello.c源程序包括注释仅有23行,但hello.i有3092行。其中包括大量的typedef,如typedef unsigned char __u_char;还包括600多行的枚举(enum)类型,以及标准C的函数原型,如extern int printf (const char *__restrict __format, …);标准输入输出和错误也在这里被定义(extern FILE *stdin;extern FILE *stdout;extern FILE *stderr;);源程序代码被放在了.i文件的最后。

2.4 本章小结

本章我们介绍了hello.c的预处理过程,大致分析了预处理后形成的hello.i文件。可以知道,仅20多行的.c文件预处理后的文件竟有3000多行。虽然产生的hello.i文件具有能够独立运行的一套源代码,而不是实现功能的代码片段,但如果编写一个hello程序需要3000行,这样的效率是极其低下的。这也就是预处理的意义:能让我们轻松写出可读性高,方便修改,利于调试的代码。

第3章 编译

3.1 编译的概念与作用

概念:编译是将预处理后的文本文件.i翻译为ASCII汇编语言的文本文件.s。编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析,语法分析,语义检查和中间代码生成,代码优化,目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。

作用:将高级程序语言翻译为统一的,接近机器语言,对机器友好的汇编语言。它为不同高级语言不同编译器提供了通用语言。如:C编译器和Fortran编译器产生的输出文件都是一样的汇编语言。

3.2 在Ubuntu下编译的命令

通过在终端输入gcc -S hello.i -o hello.s可以生成编译产生的.s文件。

程序人生-Hello’s P2P_第3张图片

图 4 编译命令

程序人生-Hello’s P2P_第4张图片

图 5 编译文件

可以看到在输入命令后,文件夹中出现了hello.s文件。

3.3 Hello的编译结果解析

3.1.1 数据 

  1. 常量

在本程序中,常量主要是指程序中出现的两个字符串常量:

"用法: Hello 学号 姓名 秒数!\n"和"Hello %s %s\n"

在汇编文件hello.s开头的.LC0和.LC1中存放了这两个字符串。

程序人生-Hello’s P2P_第5张图片

图 6 字符串常量

我们可以看到这两个字符串常量都是存放在.rodata节中的,即只读数据。

注:

.LCn:是 Local Constant 的缩写。即指局部const常量。

.LFBn:是 Local Function Beginning 的缩写。

.LFEn:是 Local Function Ending 的缩写。

.LBBn:是 Local Block Beginning 的缩写。

.LBEn:是 Local Block Ending 的缩写。

当然,除了这两个字符串常量以外,程序中也出现了立即数常量,这是一个很常见的,比如在调用exit(1)时:

程序人生-Hello’s P2P_第6张图片

图 7 片段1

未添加选项-m64 -no-pie -fno-PIC

程序人生-Hello’s P2P_第7张图片

图 8 片段2

(用作对比)

程序人生-Hello’s P2P_第8张图片

图 9 片段3

我们可以看到对于这种常量它会直接用$加数字的形式来表示。

  1. 变量

在本程序中,变量只有局部变量,主要有argc和i。而我们都知道局部变量是存储在栈中的。

我们首先来看在汇编文件中是如何存储argc的,先找到与其相关的代码:

程序人生-Hello’s P2P_第9张图片

图 10 片段4

而我们再来看相关的汇编代码:

程序人生-Hello’s P2P_第10张图片

图 11 片段5

通过将一个数与4进行比较,我们可以判断-20(%rbp)中存储的即是argc,因此可以得知argc是存放在栈中位于基址%rbp下的20个字节处。

而同理i的存放也是类似的。

程序人生-Hello’s P2P_第11张图片

图 12 片段6

程序人生-Hello’s P2P_第12张图片

图 13 片段7

可以推断,在此程序中,局部变量i是存放在栈中位于基址%rbp下的4个字节处的。

3.1.2 赋值

赋值这一操作是十分常见的,而它在汇编语言中也十分简单,一般是采用movx的操作,其中x有b、w、l等,分别代表8、16、32位的长字值。当然也有其他的赋值指令,在这里就不进行叙述。

程序人生-Hello’s P2P_第13张图片

图 14 片段8

如上面这个例子,当给整型i赋值为0时,直接用movl操作即可。

3.1.3 算术操作

在本程序中,算术操作主要是加法,就拿循环中i++而言,其汇编语言如下:

程序人生-Hello’s P2P_第14张图片

图 15 片段9

可以看到,它直接用addl进行加法操作。

3.1.4 关系操作及控制转移

一般而言,关系操作和控制转移往往会一起发生,在本程序中,我们拿将argc与4比较的汇编语言进行分析。

程序人生-Hello’s P2P_第15张图片

图 16 片段10

可以看到,他用cmpl进行判断,判断完后它利用je进行控制转移,je表示若相等则跳转,但看起来实现很简单,实际上它在用cmp进行比较时,实质上是进行了减法,然后在je跳转操作中回去判断相应的标志寄存器的值,进而来实现转移,在控制转移指令中还有很多,比如jne、jmp等等,而在关系判断时,有时编译器还会用test来代替cmp比较一个数和0,从而避免了减法运算。

3.1.5 函数操作

本程序中我们主要去分析printf和exit函数,这里为什么先来分析函数操作而不是按照ppt中的先分析数组操作,是由于先通过分析函数传入参数的方式,我们后面可以更好去判断数组的相应下标。相应汇编代码如下:

分析之前,我们首先要知道,在64位系统中,传递的参数会以如下方式进行传递,%rdi %rsi %rdx %rcx %r8 %r9依次用来传递函数的前六个参数,而后面的其他参数则会用堆栈来传递。因此这里我们可以知道传给rdi的就是printf的第一个参数,即"用法: Hello 学号 姓名 秒数!\n"的字符串常量,存储在.LC0的位置;同理exit它的第一参数就是1,传递完参数后就会call进行运行;运行之后,函数的返回值会存储在rax寄存器中。

3.1.6 数组操作

有了前面的基础,我们就可以更好去分析程序在存储数组及访问数组是怎么操作的了。

程序人生-Hello’s P2P_第16张图片

图 17 片段11

未添加选项-m64 -no-pie -fno-PIC

程序人生-Hello’s P2P_第17张图片

图 18 片段12

这一部分是for循环内的语句,通过分析传递的参数,我们可以推断-32(%rbp)+16的位置存储的是argv[2],同理-32(%rbp)+8 -32(%rbp)+24分别存储的是argv[1] argv[3],即数组也是存储在栈中,但注意这里它的大小是一个char指针,在64位系统中即8个字节,故在栈中每8个存储一个数。

3.4 本章小结

本节主要介绍编译器ccl通过编译由.i文件生成汇编语言的.s文件的过程,并分析了常量,变量,赋值,函数等各类C语言的数据与操作的汇编表示。汇编语言和高级语言很不同,即使是高级语言中一个简单的条件语句或者循环语句在汇编语言中都需要涉及到更多步骤来实现。学习汇编语言与编译,使我们能够真正的理解计算机底层的一些执行方法,有利于我们以后对程序的优化或调试。可以这么说,编译器简直就是艺术。

第4章 汇编

4.1 汇编的概念与作用

       所谓汇编,就是汇编器 (as) 将 hello.s 翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在文件 hello.o 中,因此hello.o 是一个二进制文件,而汇编也就是从人能够读懂的字符翻译成为cpu能够读懂的二进制程序码的过程。

当编译器将c源代码一路翻译成汇编代码之后,仍然不是及其可以读懂的格式。cpu在运行程序时通过机器码来判断所要执行的指令,因此还需要将ascii格式的汇编代码转化为机器码。

但需要注意的是,汇编仍然是一个中间过程。我们所编写的程序包含着在外部的库中定义的函数,同时也缺少从系统进入程序的中间函数。

更进一步,当代码越写越大之后,可能会出现更多的定义和引用分离的情况,例如一个函数在一个.c源文件中定义,而被另一个.c文件中的函数引用。在这种情况下,预处理到编译,不过是将单个的.c文件进行了翻译。

要想程序完整可用,还需要一个将多个文件合并成一个完整的可执行文件的过程,这个过程就是链接,而汇编就是在文件中根据汇编代码生成一些能够指引链接过程进行的数据结构。

形象的说,我们已经造好了一台汽车的所有零件,在将零件组装起来之前,我们现在要做的就是打造螺丝钉。

4.2 在Ubuntu下汇编的命令

       汇编命令:gcc -c -m64 -no-pie -fno-PIC  hello.s -o hello.o

程序人生-Hello’s P2P_第18张图片

图 19 汇编命令

程序人生-Hello’s P2P_第19张图片

图 20 汇编文件

可以看到生成了hello.o文件

4.3 可重定位目标elf格式

hello.o 文件在 x86-64 Linux 和 Unix 系统中使用可执行可链接格式即 (ELF),典型的 elf 可重定位目标文件格式如下:

程序人生-Hello’s P2P_第20张图片

图 21 elf可重定位目标文件

接下来利用readelf来分析其中一些节,如ELF头,节头部表,符号表,还有重定位条目,其中的其他节在反汇编中会体现。

4.3.1 ELF

      使用readelf -h hello.o查看ELF

程序人生-Hello’s P2P_第21张图片

图 22 ELF头

EFL 头以 16 字节的序列 Magic 开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。我们可以看到它的类型是REL,及可重定位目标文件。

EFL头中还包括程序的入口点地址,也就是程序运行时要执行的第一条指令的地址为0x0,而通过后面的反汇编结果我们也可以进行验证:

程序人生-Hello’s P2P_第22张图片

图 23 反汇编文件

4.3.2 节头部表

      使用 readelf -S hello.o 命令查看节头部表(在节头部表中静态和动态的基本相同,故不多做叙述)

程序人生-Hello’s P2P_第23张图片

图 24 节头部表

节头部表其描述了每个节的语义,包括每个节的类型,地址,大小等(这里由于用的是中文包,可能有些翻译不太合适,如flags中声明的读写权限等)。

     

程序人生-Hello’s P2P_第24张图片

图 25 节头部表权限关键词

4.3.3 符号表

使用 readelf -s hello.o 命令查看 .symtab 节中的 ELF 符号表

程序人生-Hello’s P2P_第25张图片

图 26 符号表

在符号表的这些条目中,value指的是该符号距定义目标的节的起始位置的偏移,size表示目标的大小,如这里main函数有152个字节,type表明它的类型,如数据还是函数,亦或者是NOTYPE,Bind表示该符号是本地的还是全局的,而Ndx表示到节头部表的索引,而其中有三个伪节未出现在节头部表的,即ABS, UNDEF, COMMON,可以看到像动态链接库里的都是UNDEF,ABS表示不该被重定义的符号,COMMON表示未初始化的全局变量,在我们这里并没出现COMMON。这里我们可以看到最后的一些符号都是其他目标模块中的函数类型为UND,比如puts,exit等。

4.3.4 重定位条目

       使用 readelf -r hello.o 命令查看 hello.o 的重定位条目:

程序人生-Hello’s P2P_第26张图片

图 27 可重定位条目

当汇编器生成 hello.o 后,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。

在这个条目中(由于中文包翻译不是很好,一切都按书上来),offset偏移量表示需要被修改的引用节偏移;type类型告诉了该重定位的类型,这些类型众多;addend表示一些重定位类型要用它来对修改引用的值进行偏移调整。

这里出现了三个类型,包括书上提到的两种最基本的重定位类型,即R_X86_64_PC32和R_X86_64_32,当然也有一个是R_X86_64_PLT32,这一个涉及到动态链接的重定位,而且我们也看到出现这个条目的名称基本都是UND类型的符号函数,即在共享库中的函数。至于如何通过重定位条目计算地址我们后面再说。

4.4 Hello.o的结果解析

通过objdump -d -r hello.o 分析hello.o的反汇编,我们将其与第3章的 hello.s进行对照分析,进而来分析二者的不同。

程序人生-Hello’s P2P_第27张图片

图 28 hello.o反汇编文件

1 机器语言的构成:

      机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指令的集合,因此机器语言是有二进制码构成的。一条指令就是机器语言的一个语句,它是一组有意义的二进制代码,指令的基本格式如,操作码字段和地址码字段,其中操作码指明了指令的操作性质及功能,地址码则给出了操作数或操作数的地址。

2)与汇编语言的映射关系:

      汇编指令是机器指令便于记忆的书写格式。每行的操作码都与其后的汇编指令一一对应。比如说mov指令对应的机器码是48%rsp%rbp寄存器对应的机器码分别是89e5,当cpu读取到mov指令后,就马上解析出这是mov指令,而如果其后面跟两个寄存器,因此又会继续读取后面的两个字节,并将其翻译成对应的寄存器,并进行操作,因此二者是一一对应,没有二义性的。

3)操作数:

      反汇编代码中的立即数是十六进制数,而 hello.s 文件中的数是十进制的。

      寄存器寻址两者相同。内存引用 hello.s 中会用伪指令(如.LC0)代替,而反汇编则是基址加偏移量寻址:0x0(%rip)

4)分支转移:

      hello.s 中,分支跳转的目标位置是通过 .L1.L2 这样的助记符来实现的,而 hello.o中,跳转的目标位置是具体的数值。但注意这个数值还不是具体的一个地址,因为此时还没进行链接,它是通过重定位条目进行计算得来的,是一个相对的地址值,由于不同文件代码链接合并和,一个文件本身的代码的相对地址不会改变,所以不需要与外部重定位,而可以直接计算出具体的数值,因此这里就已经完成了所有的操作,这条语句将以这种形式加载到内存中被cpu读取与执行。

5)函数调用:

      hello.s中,用call指令进行调用函数时,总会在call指令后直接加上函数名,而通过.o文件反汇编得到的汇编代码中,call指令后会跟着的是函数通过重定位条目指引的信息,由于调用的这些函数都是未在当前文件中定义的,所以一定要与外部链接才能够执行。在链接时,链接器将依靠这些重定位条目对相应的值进行修改,以保证每一条语句都能够跳转到正确的运行时位置。

       这里还有一个小区别是在反汇编中,它在汇编指令间会对一些符号插入可重定位条目到它的下一行。

4.5 本章小结

       汇编器对编译器生成的汇编代码文件更深一层,翻译成机器代码文件,也就是可重定位目标文件。由于每个文件中只有一部分的函数,且文件直接互相引用,互相依赖。与此同时,对于链接器来说,每个文件不过是一个字节块,要想解决这些字节块内部之间的互联逻辑,就需要汇编器多做一些,再将汇编代码翻译成机器代码时加入一些能够引导链接器进行链接的数据结构。至此,汇编器的工作就结束了,离成功不过寸步之遥。

       而在本章中,分析了汇编的过程,并分析了 ELF 头、节头部表、重定位节以及符号表。比较了 hello.s 和 hello.o 反汇编之后的代码的不同。

第5章 链接

5.1 链接的概念与作用

概念:链接是将各种代码和数据片段收集并组合成为了一个单一文件的过程,这个文件可被加载(复制)到内存并执行。

作用:链接在软件开发中扮演着重要角色,因为它使得分离编译成为可能。我们可以将软件进行模块化设计,然后模块化编程,这样分组工作高效。而且,需要修改或者调试时,只需要修改某个模块,然后简单地重新编译它,并重新链接,而不是全部重新编译链接。即使对hello这样一个非常简单的小程序,链接的作用也是巨大的。

5.2 在Ubuntu下链接的命令

Linux下使用链接器 (ld) 链接的命令为:

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

程序人生-Hello’s P2P_第28张图片

图 29 链接命令

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

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

在可执行目标文件中,其各类信息与在可重定位目标文件中其实是差不多的,故我们这里一些基本概念就不多叙述。

5.3.1 ELF

      查看hello的ELF头:readelf -a hello

程序人生-Hello’s P2P_第29张图片

图 30 ELF头

我们可以看到可文件的类型为可执行文件,入口点地址为0x4010f0,以及其他的基本信息。比如节头部表的偏移量,ELF头的大小等

       5.3.2 节头部表

              以下是部分的节头部表

程序人生-Hello’s P2P_第30张图片

图 31 部分节头部表

       可以看到,其基本内容和之前还是很相似的,但链接器将各个文件对应的段都合并了,并且重新分配并计算了相应节的类型、位置和大小等信息。通过节头部表我们就可以知道各个节的地址和大小了。而这些我们都可以到其反汇编文件中得到验证。

       5.3.3 符号表

              以下是部分符号表

程序人生-Hello’s P2P_第31张图片

图 32 符号表

5.3.4 段节

程序人生-Hello’s P2P_第32张图片

图 33 段节

5.3.5 Dynamic section

程序人生-Hello’s P2P_第33张图片

图 34 动态节

5.4 hello的虚拟地址空间

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

程序人生-Hello’s P2P_第34张图片

图 35 edb内存空间截图

该图展示了edb中data dump的默认显示地址范围,是从0x401000到0x402000.然后来看一下hello的程序头表确认它所对应的段

程序人生-Hello’s P2P_第35张图片

图 36 程序头表

程序头表描述了每个段在文件中的位置、大小以及它被放进内存后所在的位置和大小。程序头表有一列是type类型,该类型用来精确的描述各段。PHDR保存程序头表,INTERP指定程序从可执行文件映射到内存之后,必须调用的解释器(就像java需要java虚拟机解释一样)。这个解释器可以通过链接其他程序库,来解决符号引用。LOAD表示一个需要从二进制文件映射到虚拟地址空间的段,其中保存了常量数据(字符串等)、程序目标代码。DYNAMIC保存了动态链接器(前面interp指定的解释器)使用的信息。GNU_STACK:权限标志,用于标志栈是否是可执行。GNU_RELRO:指定在重定位结束之后哪些内存区域是需要设置只读。

而每一段的Filesiz关键字和Memsiz关键字给出了每一段的文件大小和占用内存的大小,PhysAddr关键字给出的是每个段的物理地址,offset是偏移值,剩下的flags,align给出的是其它的信息,这样我们就知道了一个可执行程序每个段在内存中的位置和其所占空间的大小。

而通过分析,我们可以知道第二个LOAD段是代码段,有读和执行权限,开始于内存地址0x401000处,总共的内存大小为0x27d字节,并且被初始化为可执行目标文件的头也是0x27d字节,其中包括ELF头,程序头部表以及.init、.text和.rodata节。

第四个LOAD段是数据段,有读和写权限,开始于内存地址0x403e50处,总共的内存大小为0x1fc字节,并用从目标文件中偏移0x2e10处开始的.data节中的0x1fc字节初始化。

这是我们再根据反汇编的代码和内存空间去比较发现是相同的。

程序人生-Hello’s P2P_第36张图片

图 37 反汇编验证

              我们这里也来对比一下第四个LOAD段的内存空间来看看

程序人生-Hello’s P2P_第37张图片

图 38 edb内存查看

       可以看到在0x403e50以前存储的都是0,而在之后存储了一些内容,当然我们这里也很难知道存储的是什么,但能说明的确是从这一段开始的,这验证了我们的想法。

5.5 链接的重定位过程分析

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

  1. hello新增了许多的函数相关的代码

程序人生-Hello’s P2P_第38张图片

图 39  hello反汇编文件

这是通过重定位实现的。

  1.  新增了节

我们可以看到hello明显多了很多其他的节,比如.init .plt等。

程序人生-Hello’s P2P_第39张图片

图 40 反汇编新增节

       init是为了初始化程序,而plt是由于共享库链接时生成的。

  1.  函数跳转转移

在函数调用时,由于hello是重定位后的可执行程序,因此其调用函数时,所调用的地址就是函数所在的确切地址,而hello.o则是调用一些相对的地址。

  1.  常量,全局变量的调用

在将字符串常量作为参数传递时,我们可以看到它直接使用了其地址来读取,而在hello.o则是要通过重定位来实现。

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

我们主要拿书上提到的两种主要重定位类型进行分析。

第一种是重定位的绝对引用,这种相对简单。

程序人生-Hello’s P2P_第40张图片

图 41 可重定位条目分析

这是在hello.o中传递字符串常量时出现的重定位条目(第一个),我们可以看到mov指令开始于节偏移0x19的位置,包括1字节的操作码0xbf,后面跟着对该字符串常量地址的32位绝对引用的占用符。通过重定位条目我们可以知道,它的引用符号为.rodata,addend为0。下面我们去看.rodata中的地址。

程序人生-Hello’s P2P_第41张图片

图 42 .rodata验证

       上图的信息量很大,我们首先通过readelf -x .rodata hello去查看hello的.rodata段的十六进制形式,可以看到是从0x402000开始的,但为什么不是下面的结果不是0x402000呢?这一点我也不太确定,但我们可以看到从0x402008开始有大量的信息存储,比如e794a8e6我们去查hello.s中看到的赋值序列,上网查询得知,这是汉字的八进制转义序列,每三个作为一个编码,转换为十六进制可以发现二者恰好对应,说明从0x402008开始存储的正是该字符串常量。

程序人生-Hello’s P2P_第42张图片

图 43 反汇编验证

       通过查询可以确认是正确的。

       而第二种相对寻址该程序中并未体现,因为它只有一个目标模块。

5.6 hello的执行流程

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

由于对edb操作不是很熟悉,我采用的是gdb的方式,我们首先通过前面说到的入口点,即_start处的地址0x4010f0,在此处设置断点

程序人生-Hello’s P2P_第43张图片

图 44 gdb设置断点

之后我们单步开始运行,然后看它跳转的函数及其地址。

程序名

程序地址

_start

0x4010f0

_libc_start_main_impl

0x7ffff7c29dc0

__GI___cxa_atexit

0x7ffff7c458c0

__new_exitfn

0x7ffff7c456d0

_init

0x401000

_dl_audit_preinit@plt

0x7ffff7c286d0

(跳至一个空的,没名字的地方)

0x7ffff7c28350

同上

0x7ffff7c28000

_dl_runtime_resolve_xsavec

0x7ffff7fd8d30

_dl_fixup

0x7ffff7fd5e70

_dl_lookup_symbol_x

0x7ffff7fcf0d4

do_lookup_x

0x7ffff7fce3f0

check_match

0x7ffff7fce24x

strcmp

0x7ffff7fea224

之后开始不断返回直到_dl_audit_preinit

__libc_start_call_main

0x7ffff7c29d10

_setjmp

0x7ffff7c421e0

_sigsetjmp

0x7ffff7c42110

__sigjmp_save

0x7ffff7c42190

main

0x401125

printf

0x4010a0

atoi

0x401191

sleep

0x4010e0

getchar

0x4010b0

返回至__libc_start_call_main

__GI_exit

0x7ffff7c455f0

__run_exit_handlers

0x7ffff7c45390

__GI___call_tls_dtors

0x7ffff7c45d60

_dl_fini

0x7ffff7fc9040

___pthread_mutex_lock

0x7ffff7c97ef0

_dl_audit_activity_nsid

0x7ffff7fde250

_dl_sort_maps

0x7ffff7fd6730

_fini

0x4011b4

_dl_audit_objclose

0x7ffff7fde570

最终跳至__GI_exit退出程序

整个过程其实调用了很多未知的函数,但总结来说过程是

_start -> __libc_start_main ->  init -> main -> exit.

5.7 Hello的动态链接分析

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

动态链接是一项有趣的技术。让我们考虑一个简单的事实,printf,getchar这样的函数实在使用的太过频繁,因此如果每个程序链接时都要将这些代码链接进去的话,一份可执行目标文件就会有一份printf的代码,这是对内存的极大浪费。为了遏制这种浪费,对于这些使用频繁的代码,系统会在可重定位目标文件链接时仅仅创建两个辅助用的数据结构,而直到程序被加载到内存中执行的时候,才会通过这些辅助的数据结构动态的将printf的代码重定位给程序执行。即是说,直到程序加载到内存中运行时,它才知晓所要执行的代码被放在了内存中的哪个位置。

这种有趣的技术被称为延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。而那两个辅助的数据结构分别是过程链接表(PLT)和全局偏移量表(GOT),前者存放在代码段,后者存放在数据段。

程序人生-Hello’s P2P_第44张图片

图 45 节头部表查看.got节

       我们首先通过前面的节头部表获得got的地址,

       可以看到.got的位置是0x403ff0,我们去查相应的内存空间。

程序人生-Hello’s P2P_第45张图片

图 46 运行前got内存空间

       可以看到在运行之前是没什么内容的,而当我们运行之后,我们来看看它的变化:

程序人生-Hello’s P2P_第46张图片

图 47 运行后got内存空间

       通过前后对比:在执行_dl_init之前,.got是全0的;在执行_dl_init之后,.got变成了相应的值。因此推测_dl_init作用是:初始化程序,给其赋上调用的函数的地址,使这些被调用的函数链接到了动态库。

5.8 本章小结

本章介绍了链接的概念与内容,分析了hello的elf格式,查看了hello的虚拟地址空间,对链接的重定位过程进行了分析,并验证了其重定位条目的引用等,并使用gdb和edb分析了hello的执行流程和动态链接分析。

在本章中呢,链接器ld通过可重定位目标文件中的数据结构,解析每个文件中的符号,仔细比对了符号的定义和引用,最终为每个符号的引用都找到了正确的符号定义的位置。而重定位的过程更加需要小心谨慎,链接器需要在特定的位置修改值,使得程序在运行时能够指哪打哪而不会偏差。毕竟在cpu中哪怕是一个字节的偏差,失之毫厘,差之千里。因此可以说,链接的过程同样是充满着各种艰难困苦的。

第6章 hello进程管理

6.1 进程的概念与作用

概念:进程的经典定义是一个执行中程序的实例,系统的每个程序都运行在某个进程的上下文。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存里的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。进程是计算机科学中最深刻,最成功的概念。

作用:通过进程,我们会得到一种假象,好像我们的程序是当前唯一运行的程序,我们的程序独占处理器和内存,我们程序的代码和数据好像是系统内存中唯一的对象。而这些假象就是通过进程来实现的。

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

Shell 是一种交互型的应用级程序,用户能够通过 Shell 与操作系统内核进行交互。

bash,全称为Bourne-Again Shell。它是一个为GNU项目编写的Unix shell。bash脚本功能非常强大,尤其是在处理自动循环或大的任务方面可节省大量的时间。bash是许多Linux平台的内定Shell。

程序人生-Hello’s P2P_第47张图片

图 48 用户内核层次图

处理流程:

  1. Shell读取用户输入的命令。
  2. Shell判断是否为shell内置命令,如果不是则认为是一个可执行文件。、
  3. Shell构建参数argv和环境变量envp用作execve加载时的参数。
  4. Shell通过fork创建子进程,再通过execve函数加载可执行文件,并跳转到程序的入口点执行程序。
  5. 当程序终止或停止后,Shell回收创建的子进程,之后再不断循环。

6.3 Hello的fork进程创建过程

我们在Shell上输入./hello,这个不是一个内置的Shell命令,所以Shell会认为hello是一个可执行目标文件,通过调用某个驻留在存储器中被称为加载器的操作系统代码来运行它。当Shell运行一个程序时,父进程通过fork函数生成这个程序的进程。这个子进程几乎与父进程相同,子进程得到与父进程相同的虚拟地址空间(独立)的一个副本,包括代码,数据段,堆,共享库以及用户栈,并且和父进程共享文件。它们之间的区别是PID不同。

通过查阅资料我们来具体讲讲fork的实现:

Linux 通过 clone() 系统调用来实现 fork(),由于 clone() 可以自主选择需要复制的资源,所以这个系统调用需要传入很多的参数标志用于指明父子进程需要共享的资源。fork(),vfork(),__clone() 函数都需要根据各自传入的参数去底层调用 clone() 系统调用,然后再由 clone() 去调用 do_fork()。do_fork() 完成了创建的大部分工作,该函数调用 copy_process() 函数,然后让进程开始运行。

copy_process() 函数是核心,其工作分为这几步:

  1. 调用 dup_task_struct() 为新进程创建一个内核栈,thread_info 结构和 task_struct,这些值和当前进程的值相同。也就是说,当前子进程和父进程的进程描述符是一致的。
  2. 检查一次,确保创建新进程后,拥有的进程数目没有超过给它分配的资源和限制。所有进程的 task_struct 结构中都有一个数组 rlim,这个数组中记载了该进程对占用各种资源的数目限制,所以如果该用户当前拥有的进程数目已经达到了峰值,则不允许继续 fork()。这个值为 PID_MAX,大小为 0x8000,也就是说进程号的最大值为 0x7fff,即短整型变量 short 的大小 32767,其中 0~299 是为系统进程(包括内核线程)保留的。
  3. 子进程为了将自己与父进程区分开来,将进程描述符中的许多成员全部清零或者设为初始值。不过大多数数据都未修改。
  4. 将子进程的状态设置为 TASK_UNINTERRUPTIBLE 深度睡眠,不可被信号唤醒,以保证子进程不会投入运行。
  5. copy_process() 函数调用 copy_flags() 以更新 task_struct 中的 flags 成员。其中表示进程是否拥有超级用户管理权限的 PF_SUPERPRIV 标志被清零,表示进程还没有调用 exec() 函数的 PF_FORKNOEXEC 标志也被清零。
  6. 调用 alloc_pid 为子进程分配一个有效的 PID
  7. 根据传递给 clone() 的参数标志,调用 do_fork()->copy_process() 拷贝或共享父进程打开的文件,信号处理函数,进程地址空间和命名空间等。一般情况下,这些资源会给进程下的所有线程共享。
  8. 最后,copy_process() 做扫尾工作并返回一个指向子进程的指针。

6.4 Hello的execve过程

execve() 函数加载并运行可执行目标文件,且带参数列表 argv 和环境变量列表 envp,execve() 函数调用一次从不返回。它的执行过程如下:

  1. 删除已存在的用户区城。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
  2. 映射私有区城。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text .data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中。栈和堆区域也是请求二进制零的,初始长度为零。图 6.3 概括了私有区域的不同映射。
  3. 映射共享区城。如果 hello 程序与共享对象(或目标)链接,比如标准C libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
  4. 设置程序计数器(PC)。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点,也就是_start 函数的地址。下一次调度这个进程时,它将从这个入口点开始执行。Linux 将根据需要换入代码和数据页面。_start 函数调用系统启动该函数__libc_start_main。它初始化执行环境,调用用户层的 main 函数,处理 main 函数的返回值,并且在需要的时候把控制返回到内核。

6.5 Hello的进程执行

6.5.1 逻辑控制流

操作系统将一个 CPU 物理控制流,分成多个逻辑控制流,每个进程独占一个逻辑控制流。当一个逻辑控制流执行的时候,其他的逻辑控制流可能会临时暂停执行。一般来说,每个逻辑控制流都是独立的。当两个逻辑控制流在时间上发生重叠,我们说是并行的。如图:

程序人生-Hello’s P2P_第48张图片

图 49 逻辑控制流

处理器在多个进程中来回切换称为多任务,每个时间当处理器执行一段控制流称为时间片。因此多任务也指时间分片。

6.5.2 用户模式和内核模式

为了限制一个应用可以执行的指令以及它可以访问的地址空间范围,处理器用一个控制寄存器中的一个模式位来描述进程当前的特权。

当设置了模式位时,进程就运行在内核模式。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。

没有设置模式位时,进程就运行在用户模式。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个 I/O 操作。也不允许用户模式的进程直接引用地址空间中内核区内的代码和数据。用户程序必须通过系统调用接口间接地访问内核代码和数据。

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

6.5.3 上下文切换

操作系统内核为每个进程维护一个上下文。所谓上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表,包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

6.5.4 hello 的执行

从 Shell 中运行 hello 时,它运行在用户模式,运行过程中,内核不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用执行,实现进程的调度。如果在运行过程中收到信号等,那么就会进入内核模式,运行信号处理程序,之后再返回用户模式。

6.6 hello的异常与信号处理

我们来结合hello的执行过程具体分析会出现哪几类异常,会产生哪些信号,又怎么处理的。

6.6.1 异常

异常可以分为四类:中断、陷阱、故障和终止。我们结合hello的执行过程具体来说明。

中断:中断一般是来自处理器外部的I/O设备的信号的结果。比如在 hello 运行过程中,我们敲击键盘,那么就会触发中断,系统调用内核中的中断处理程序执行,然后返回,返回到hello的下一条指令继续执行。

陷阱:陷阱是有意的异常,一般是用来提供用户程序和内核之间的接口,即系统调用。我们的 hello 运行在用户模式下,无法直接运行内核中的程序,比如像 fork,exit 这样的系统调用。于是就通过陷阱的方式,执行 systemcall 指令,内核调用陷阱处理程序来执行系统调用。

故障:故障是有一些错误情况引起的,当故障发生时,处理器会将控制转移给故障处理子程序,若能修正,则返回到原指令重新执行,否则就会终止。一个经典的故障是缺页异常,比如当我们的 hello 运行时,当某一条指令引用一个虚拟地址,而地址相对应的物理页面不在内存中,就会发生故障。内核调用故障处理程序(这里是缺页处理程序),缺页处理程序从磁盘中加载适当的页面,然后将控制返回给引起故障的指令,该指令就能没有故障地执行了。

当然,在hello的执行过程中也有一些故障会使程序直接终止。

终止:hello 在运行时,也有可能遇到硬件错误,比如说DRAM或者SRAM位被损坏时发生的奇偶错误,这时处理程序会终止hello程序。

6.6.2 信号

信号,是一种更高层的软件形式的异常,它允许进程和内核中断其他进程。Linux中的信号有很多种,我们可以用kill -l命令查看。

程序人生-Hello’s P2P_第49张图片

图 50 信号类型

接下来,我们通过在程序运行时通过按键盘,从键盘发送信号,以及输入各种命令对于程序运行进程中的异常与信号的处理做一个更深入的了解。

  • 不停乱按键盘

程序人生-Hello’s P2P_第50张图片

图 51 测试1

程序人生-Hello’s P2P_第51张图片

图 52 测试2

       可以看到,在程序运行过程中,只要hello不停止,我们输入的命令会显示在屏幕上,但是Shell不会在显示运行hello的过程运行他们,而是类似将它们放入缓冲区,当程序运行后,它会像运行其他命令一样对它们进行解析,正常运行相应的命令。

  • Ctrl-Z

程序人生-Hello’s P2P_第52张图片

图 53 测试3

       按下Ctrl-Z后我们可以发现它显示该进程已停止,但它其实并未终止,它被Shell放在后台中,我们可以用一些指令查看。

-ps

程序人生-Hello’s P2P_第53张图片

图 54 测试4

       可以看到hello的相关进程信息。

-jobs

程序人生-Hello’s P2P_第54张图片

图 55 测试5

-pstree

程序人生-Hello’s P2P_第55张图片

图 56 测试6

       输入pstree可以看到它和其它进程的一些关系。

-fg

程序人生-Hello’s P2P_第56张图片

图 57 测试7

       可以看到程序又正常运行。

-kill

程序人生-Hello’s P2P_第57张图片

图 58 测试8

       我们输入kill %1表示终止第一个作业,我们再用jobs查看可以看到该作业已终止。

       ·Ctrl-C

       当hello在前台运行时,按下Ctrl-C会发送SIGINT信号,进而使进程终止。

6.7本章小结

本章了解了hello进程的执行过程。在hello运行过程中,内核对其调度,异常处理程序为其将处理各种异常。每种信号都有不同的处理机制,对不同的shell命令,hello也有不同的响应结果。可以说,进程管理就是为了约束程序的运行而存在的。

第7章 hello的存储管理

7.1 hello的存储器地址空间

首先我们结合hello程序来说明逻辑地址、线性地址、虚拟地址、物理地址的概念。hello程序经过反汇编得到的内存地址是逻辑地址,需要转换成线性地址,再经过MMU(CPU中的内存管理单元)转换成物理地址才能够被访问到。

逻辑地址:包含在机器语言中用来指定一个操作数或一条指令的地址。每一个逻辑地址都由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离。通俗的说:逻辑地址是给程序员设定的,底层代码是分段式的,代码段、数据段、每个段最开始的位置为段基址,放在如CS、DS这样的段寄存器中,再加上偏移,这样构成一个完整的地址。

另一些说法:逻辑地址(Logical Address 是指由程序产生的与段相关的偏移地址部分。例如,进行C语言指针编程中,可以读取指针变量本身值(&操作),实际上这个值就是逻辑地址,它是相对于你当前进程数据段的地址,不和绝对物理地址相干。只有在Intel实模式下,逻辑地址才和物理地址相等(因为实模式没有分段或分页机制,Cpu不进行自动地址转换);逻辑也就是在Intel 保护模式下程序执行代码段限长内的偏移地址(假定代码段、数据段如果完全一样)。应用程序员仅需与逻辑地址打交道,而分段和分页机制对您来说是完全透明的,仅由系统编程人员涉及。应用程序员虽然自己可以直接操作内存,那也只能在操作系统给你分配的内存段操作。

线性地址(Linear Address) 是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386的线性地址空间容量为4G(2的32次方即32根地址总线寻址)。

虚拟地址:保护模式下,hello 运行在虚拟地址空间中,它访问存储器所用的逻辑地址。

物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,每一个字节单元给以一个唯一的存储器地址,称为物理地址,指出现CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。

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

在 Intel 平台下的实模式中,逻辑地址为:CS:EA,CS 是段寄存器,将 CS 里的值左移四位,再加上 EA 就是线性地址。

而在保护模式下,要用段描述符作为下标,到 GDT(全局描述符表)/LAT(局部描述符表)中查表获得段地址,段地址+偏移地址就是线性地址。一个逻辑地址是由段选择符和偏移地址组成的。段选择符存放在段寄存器(16 位)。前 13 位表示其段描述符在对应描述符表的索引号,后面 3 位分别是TI和RPL,TI用于说明是GDT还是LDT,RPL用于说明特权等级。如图是段选择符各字段含义:

程序人生-Hello’s P2P_第58张图片

图 59 段选择符各字段

其中,从段描述符中可以得到:

(1)段的基地址(Base Address):在线性地址空间中段的起始地址。

(2)段的界限(Limit):表示在逻辑地址中,段内可以使用的最大偏移量。

(3)段的属性(Attribute):表示段的特性。例如,该段是否可被读出或写入,或者该段是否作为一个程序来执行,以及段的特权级等。

从段描述符和偏移地址得到线性地址的过程如图:

程序人生-Hello’s P2P_第59张图片

图 60 逻辑地址转为线性地址

给定一个完整的 48 位逻辑地址[段选择符(16 位):段内偏移地址(32 位)],首先看段选择符的T1=0还是1,是0要转换 GDT 中的段,是1转换 LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个段描述符表了。然后拿出段选择符中前13位,可以在段描述符表中通过索引,查找到对应的段描述符,这样,就得到了32位段基地址。最后把32位段基地址和32位段内偏移量相加,就是要转换的线性地址了。

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

7.3.1 Intel的页式管理页式

内存管理单元负责把一个线性地址转换为物理地址。从管理和效率的角度出发,线性地址被划分成固定长度单位的数组,称为页(page)。例如,一个32位的机器,线性地址可以达到4G,每页4KB,这样,整个线性地址就被划分为2^20页,称之为页表,该页表中每一项存储的都是物理页的基地址。为了能够尽可能的节约内存,CPU在页式内存管理方式中引入了多级页表的方式。

VM 系统将虚拟内存分割为成为虚拟页的大小固定的快,物理内存也被分割为物理页,成为页帧。虚拟页面就可以作为缓存的工具,被分为三个部分:

未分配的:VM 系统还未分配的页

已缓存的:当前已缓存在物理内存中的已分配页

程序人生-Hello’s P2P_第60张图片

图 61 虚拟内存分配

未缓存的:未缓存在物理内存的已分配页

7.3.2 hello的线性地址到物理地址的变换

从线性地址到物理地址的变换,我们以资料中的系统为例,

程序人生-Hello’s P2P_第61张图片

图 62 线性地址到物理地址变换

据以下步骤进行转换:

  1. 从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
  2. 根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
  3. 根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
  4. 将页的起始地址与线性地址中最后12位相加,得到最终我们想要的地址。

其中,这里CR3是页目录基址寄存器,用于保存页目录表的起始地址。

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

       如图是Core i7在TLB和四级页表支持下从虚拟地址VA到物理地址PA的转换:

程序人生-Hello’s P2P_第62张图片

图 63 虚拟地址翻译过程

TLB(Translation Lookaside Buffer,翻译后备缓冲器)俗称快表,用于加速地址翻译。每次CPU产生一个虚拟地址,就必须查询PTE(页表条目),在最差情况下它在内存中读取PTE,耗费几十到几百个周期。TLB是一个对PTE的缓存,每当查询PTE时,MMU先询问TLB中是否存有该条目,若有,它可以很快地得到结果;否则,MMU需要按照正常流程到高速缓存/内存中查询PTE,把结果保存到TLB中,最后在TLB中取得结果。

多级页表用于减少常驻于内存中的页表大小。由于在同一时间并非所有虚拟内存都被分配,那么操作系统可以只记录那些已被分配的页来减小内存开销,这通过多级页表实现。对于一个k(k>1)级页表,虚拟地址被分为k个虚拟页号和一个虚拟页偏移量。为了将虚拟地址转换为物理地址,MMU首先用第一段虚拟页号查询常驻于内存的一级页表,获取其二级页表的基址,再用第二段虚拟页号查询三级页表的基址,直到对第k级页表返回物理地址偏移,MMU就得到了该虚拟地址对应的物理地址。对于那些没有分配的虚拟地址,对应的多级页表根本不存在,只有当分配到它们时才会创建这些页表。因此,多级页表能够减少内存需求。

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

得到物理地址后,将物理地址分为 CT(标记位)、CI(组索引) 和 CO(块偏移)。根据 CI 查找 L1 缓存中的组,依次与组中每一行的数据比较,有效位有效且标记位一致则命中。如果命中,直接返回想要的数据。如果不命中,就依次去 L2、L3 缓存判断是否命中,命中时将数据传给 CPU 同时更新各级缓存。

具体过程如下:

1. 先在第一级缓存中寻找要找的数据,利用TLBI找到TLB中对应的组,再比较TLBT,若相同且有效为为1,则要找的数据就是该组的数据。

2. 否则,在第二级缓存中寻找,找到后需要再将其缓存在第一级,若有空闲块,则放置在空闲块中,否则根据替换策略选择牺牲块。

3. 否则,在第三级缓存中寻找,找到后需要缓存在第一,二级,若有空闲块,则放置在空闲块中,否则根据替换策略选择牺牲块。

4. 否则,在第四级缓存中寻找,找到后需要缓存在第一,二,三级,若有空闲块,则放置在空闲块中,否则根据替换策略选择牺牲块。

7.6 hello进程fork时的内存映射

在 Shell 输入命令行后,内核调用fork创建子进程,为 hello 程序的运行创建上下文,并分配一个与父进程不同的PID。通过 fork 创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用 fork 时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。

程序人生-Hello’s P2P_第63张图片

图 64 一个私有的写时复制对象

7.7 hello进程execve时的内存映射

execve() 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。加载并运行 hello 需要以下几个步骤:

  1. 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
  2. 映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的 .text 和 .data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
  3. 映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
  4. 设置程序计数器(PC),execv() 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。

程序人生-Hello’s P2P_第64张图片

图 65 加载时内存空间

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

正如前面讲到的一样,当MMU试图翻译某个虚拟地址A时,如果在TLB中找不到相应的页表项,说明发生了一个缺页故障异常,进而导致控制转移到内核的缺页处理子程序,而在处理程序中,它并不是直接到磁盘中将相应页面替换进来的,它会进行以下操作:

  1. 判断该虚拟地址A是否合法,即判断它是否在hello程序中的某个虚拟内存区域中,如果不是则触发一个段错误,终止程序。
  2. 判断该虚拟地址进行的内存访问是否合法,第一部已经判断了A是在某个区域中的,但接下来要判断执行hello程序的进程是否有权限访问A所在的这个区域,如果不合法,则触发一个保护异常,终止进程。
  3. 此时,内核已经知道该缺页是由于对合法虚拟地址的翻译导致的,那么它会去判断缓存是否已满,若已满还会去选择一个牺牲页,然后去更新页表,最后再重新执行导致缺页的指令。

程序人生-Hello’s P2P_第65张图片

图 66 linux缺页处理

7.9动态存储分配管理

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

当程序运行时,如果需要额外的虚拟内存时,可以用动态内存分配器来申请内存。动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap),假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。

对于每个进程,内核维护着一个变量 brk,它指向堆的顶部。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

隐式空闲链表分配中,内存块的基本结构如下:

程序人生-Hello’s P2P_第66张图片

图 67 使用边界标记的堆块的格式

而对于显式空间链表,真实的操作系统实际上使用的是显示空闲链表管理。它的思路是维护多个空闲链表,每个链表中的块有大致相等的大小,分配器维护着一个空闲链表数组,每个大小类一个空闲链表,当需要分配块时只需要在对应的空闲链表中搜索就好了。

其一种实现的基本结构如下:

程序人生-Hello’s P2P_第67张图片

图 68 显示空间链表基本结构

7.10本章小结

       本章介绍了现代操作系统的灵魂:存储器地址空间、段式管理、页式管理,VA 到 PA 的变换、物理内存访问, hello 进程 fork 时和 execve 时的内存映射、缺页故障与缺页中断处理、包括隐式空闲链表和显式空闲链表的动态存储分配管理。这些巧妙的设计使得我们的 hello 最终得以运行。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

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

Linux的设备管理的主要任务是控制设备完成输入输出操作,所以又称输入输出(I/O)子系统。它的任务是把各种设备硬件的复杂物理特性的细节屏蔽起来,提供一个对各种不同设备使用统一方式进行操作的接口。Linux把设备看作是特殊的文件,系统通过处理文件的接口—虚拟文件系统VFS来管理和控制各种设备。

8.2 简述Unix IO接口及其函数

8.2.1 I/O接口操作

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

②Linux shell创建的每个进程开始时都有三个打开的文件:标准输入、标准输出和标准错误。

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

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

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

8.2.2 I/O函数

①int open(char *filename, int flags, mode_t mode);进程通过调用open函数来打开一个已存在的文件或者创建一个新文件。open函数将filename转换为一个文件描述符,而且返回描述符数字。flags参数指明了进程打算如何访问这个文件。mode参数指定了新文件的访问权限位。

②int close(int fd);进程通过调用close函数关闭一个打开的文件。

③ssize_t read(int fd, void *buf, size_t n);应用程序通过调用read函数来执行输入。read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,返回值0表示EOF。否则返回值表示的是实际传送的字节数量。

④ssize_t write(int fd, const void *buf, size_t n);应用程序通过调用write函数来执行输出。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。(见书P624~P626)

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;

}

而这里面的vsprintf 函数将所有的参数内容格式化之后存入 buf,返回格式化数组的长度,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生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等. 这里的话syscall的实现是很复杂的,超出了我们的讨论范围,故我们就不多叙述了。

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。程序会根据每个符号所对应的ascii码,系统会从字模库中提取出每个符号的vram信息。

显卡使用的内存分为两部分,一部分是显卡自带的显存称为VRAM内存,另外一部分是系统主存称为GTT内存(graphics translation table和后面的GART含义相同,都是指显卡的页表,GTT 内存可以就理解为需要建立GPU页表的显存)。在嵌入式系统或者集成显卡上,显卡通常是不自带显存的,而是完全使用系统内存。通常显卡上的显存访存速度数倍于系统内存,因而许多数据如果是放在显卡自带显存上,其速度将明显高于使用系统内存的情况(比如纹理,OpenGL中分普通纹理和常驻纹理)。

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

8.4 getchar的实现分析

getchar 由宏实现:#define getchar() getc(stdin)。

getchar 有一个int型的返回值.当程序调用getchar时.程序就等着用户按键.用户输入的字符被存放在键盘缓冲区中.直到用户按回车为止(回车字符也放在缓 冲区中).当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符.getchar函数的返回值是用户输入的第一个字符的ASCII 码,如出错返回-1,且将用户输入的字符回显到屏幕.如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用 读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键.

hello 程序调用 getchar 后,它就等着键盘输入。当我们输入时,会发生异常,内核中的键盘中断处理子程序来进行处理。而异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar通过陷阱调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。此时getchar 开始从read返回的字符串中读入其第一个字符。

getchar 函数的返回值是用户输入的字符的 ASCII 码,若文件结尾则返回 -1(EOF),且将输入的字符回显到屏幕。

8.5本章小结

       IO是复杂的计算机内部与外部沟通的通道。尽管我们时时刻刻都在使用着IO:通过键盘输入,通过屏幕阅读。但是系统IO实现的细节同样也是相当复杂的。

本章介绍了linux系统下的IO的基本知识,讨论了IO在linux系统中的形式以及实现的模式。然后对printf和getchar两个函数的实现进行了深入的探究。

结论

至此,hello 终于走完了它的一生,让我们为它的一生做个小结:

Hello程序的生命周期开始于程序员把其内容输入到文本编辑器中:字符数据经过总线最终被传输到寄存器,并在文件被关闭后保存到磁盘。

接下来将源文件通过gcc编译器预处理,编译,汇编,链接,最终完成一个可以加载到内存执行的可执行目标文件。一系列操作,为hello.c一个空壳注入了活的灵魂。

接下来通过shell输入文件名,shell通过fork创建一个新的进程,然后在子进程里通过execve函数将hello程序加载到内存。虚拟内存机制通过mmap为hello规划了一片空间,调度器为hello规划进程执行的时间片,使其能够与其他进程合理利用cpu与内存的资源。此时的它,才真正成为系统中独立的个体,往回看,逻辑控制流、虚拟地址、malloc的高效管理、异常与信号管理,这些都为它的驰骋拥有更加广阔的天地。

然后,cpu一条一条的从hello的.text取指令执行,不断从.data段去除数据。异常处理程序监视着键盘的输入。hello里面的一条syscall系统调用语句使进程触发陷阱,内核接手了进程,然后执行write函数,将一串字符传递给屏幕io的映射文件。文件对传入数据进行分析,读取vram,然后在屏幕上将字符显示出来。可以说,Unix I/O 打开它与程序使用者交流的窗口。

直至最后hello“垂垂老矣”,运行至最后一刻,程序运行结束,__libc_start_main 将控制转移给内核,Shell 回收子进程,内核删除与它相关的所有数据结构,它在这个世界的所有痕迹至此被抹去。

从键盘上敲出hello.c的源代码程序不过几分钟,从编译到运行,从敲下gcc到终端打印出hello信息,可能甚至不需要1秒钟。

但回首这短短的1秒,却惊心动魄,千难万险,其中的每一阶段无不汇集凝结了人类几十年的智慧与心血!

高低电平传递着信息,这些信息被复杂而严谨的机器逻辑捕捉。cpu不知疲倦的取指与执行。对于hello的实现细节,哪怕把这篇论文再扩充一倍仍讲不清楚。

附件

  1. hello.c:源代码文件。
  2. hello.i:预处理后的文本文件。
  3. hello.s:编译后的汇编文件。
  4. hello.o:汇编后的可重定位文件。
  5. hello:链接后的可执行文件。
  6. hello.o.txt:hello.o反汇编得到的文本文件。
  7. hello.txt:hello反汇编得到的文本文件。

参考文献

  1. Randal E. Bryant & David R. O’Hallaron. 深入理解计算机系统[M]. 北京:机械工业出版社,2019.
  2. printf 函数实现的深入剖析https://www.cnblogs.com/pianist/p/3315801.html
  3.        GCC编译的四个阶段 http://t.csdnimg.cn/dMVxa
  4. 深入浅出GNU X86-64 汇编

       https://www.cnblogs.com/zhumengke/articles/10643032.html

  1. x86寄存器问题 http://t.csdnimg.cn/x8PaE
  2. readelf命令使用说明 http://t.csdnimg.cn/ROXm9
  3. 逻辑地址、线性地址、物理地址和虚拟地址理解 http://t.csdnimg.cn/WvHfR
  4. linux编程之main()函数启动过程

https://www.cnblogs.com/sky-heaven/p/8422023.html

你可能感兴趣的:(linux)