程序人生-Hello’s P2P

 

摘  要

编程就是一种语言翻译为另一种语言的过程,我们按照规则,将自己能看懂、带有目的的符号组合在一起形成高级语言,通过一系列指令将这种高级语言变成机器能读懂的二进制语言,在软硬件协同配合下,生成了我们期望中的结果。

Helloworld几乎是所有程序员编写的第一个程序,本文围绕这个较简单的hello.c函数的整个生命周期展开,介绍了该示例程序从编写到执行到结束的全过程,详细分析了每个阶段的原理和具体操作过程,以及可能发生的一些异常情况等。此过程中将通过Linux中的命令行解析器shell进行操作,并使用edb等调试工具,对计算机系统编译源文件、运行进程等机制进行较深入的分析和介绍,增加对计算机体系结构的深刻认识。

关键词:预处理;编译;汇编;链接;进程;存储;IO                           

               

目  录

第1章 概述... - 5 -

1.1 Hello简介... - 5 -

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

1.2.1 硬件环境... - 5 -

1.2.2 软件环境... - 5 -

1.2.3 开发工具... - 5 -

1.3 中间结果... - 5 -

1.4 本章小结... - 6 -

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

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

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

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

2.4 本章小结... - 10 -

第3章 编译... - 11 -

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

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

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

3.4 本章小结... - 20 -

第4章 汇编... - 21 -

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

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

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

4.3.1 readelf命令... - 21 -

4.3.2 分析ELF格式... - 21 -

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

4.5 本章小结... - 25 -

第5章 链接... - 25 -

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

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

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

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

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

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

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

5.8 本章小结... - 34 -

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

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

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

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

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

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

6.5.1 逻辑控制流和时间片... - 37 -

6.5.2 用户模式和内核模式... - 37 -

6.5.3 上下文... - 37 -

6.5.4 调度的过程... - 37 -

6.5.5 用户态与核心态转换... - 37 -

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

6.6.1正常运行状态... - 38 -

6.6.2异常类型与处理方式... - 38 -

6.6.3信号... - 38 -

6.7本章小结... - 40 -

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

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

7.1.1 逻辑地址... - 41 -

7.1.2 线性地址... - 41 -

7.1.3 虚拟地址... - 41 -

7.1.4 物理地址... - 41 -

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

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

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

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

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

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

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

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

7.10本章小结... - 45 -

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

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

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

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

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

8.5本章小结... - 48 -

结论... - 48 -

附件... - 50 -

参考文献... - 51 -

第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

在编辑好源文件之后,通过cpp、gcc、ld等命令,将C语言的源文件进行预处理、编译、汇编和链接,最终形成可执行目标文件hello,由存储器保存在磁盘中。它通过fork创建进程,被execve函数加载至内存,顺着逻辑控制流,在硬件上完成取指、译码、执行,最终显示在屏幕上,程序终止后由shell进行回收。

1.2 环境与工具

1.2.1 硬件环境

系统类型       64 位操作系统, 基于 x64 的处理器

版本           20H2(操作系统内部版本    19042.1526)

处理器       Intel(R) Core(TM) i7-1065G7 CPU @ 1.30GHz   1.50 GHz

机带        RAM  16.0 GB (15.8 GB 可用)

磁盘驱动器  INTEL SSDPEKN 512G8H

WDC WD10 SPZX-22Z10T1

1.2.2 软件环境

Windows10 X64;VirtualBox/Vmware15.5.0;Ubuntu 20.04.3 LTS _Focal Fossa_ - Release amd64 (20210819)

1.2.3 开发工具

Visual Studio Community2019 16.11.5 X64

Code::Blocks Release 20.03  rev 11997 2020-04-18, 19:47:24 - wx3.0.4 - gcc 9.3.0 (Linux, unicode) - 64 bit

gcc version 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04)

1.3 中间结果

文件名称

文件作用

hello.c

源文件

hello.i

经预处理后的文件

hello.s

编译之后的汇编文件

hello.o

汇编之后的可重定位目标文件

hello.txt

hello.o的ELF格式文件

dump_hello.txt

hello.o的反汇编文件

hello

链接之后的可执行目标文件

outELF.txt

hello的ELF格式文件

hello_objdump.s

hello反汇编文件

表一: 中间文件及作用

1.4 本章小结

本章对hello的一生进行了简要的介绍和描述,介绍了P2P的整个过程,介绍了本计算机的硬件环境、软件环境、开发工具,介绍了为编写本论文的中间文件的名称和其作用。

第2章 预处理

2.1 预处理的概念与作用

(1)预处理概念

程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。

典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。

预处理器在UNIX传统中通常缩写为PP,在自动构建脚本中C预处理器被缩写为CPP的宏指代。为了不造成歧义,C++(cee-plus-plus) 经常并不是缩写为CPP,而改成CXX。

(2)预处理作用

预处理能改善程序设计的环境, 有助于编写易移植、易调试的程序, 也是模块化程序设计的一个工具。对编译预处理命令的灵活运用, 可以使程序结构优良, 更加易于调试和阅读。

最常见的预处理是C语言和C++语言。ISO C和ISO C++都规定程序由源代码被翻译分为若干有序的阶段(phase),通常前几个阶段由预处理器实现。预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。

2.2在Ubuntu下预处理的命令

在Ubuntu中使用预处理指令

 图2-1 预处理指令

出现文件hello.i

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

图2-2 生成hello.i

2.3 Hello的预处理结果解析

查看内容首先会发现hello.i文件有3000多行,而hello.c文件仅有20多行。

多出来的代码是什么?

  1. 第一部分  调用的库文件

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

 图2-3 hello.i库文件

  1. 第二部分  各种结构体的声明和定义

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

图2-4 结构体声明与定义 

  1. 第三部分  对内部函数的声明

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

 图2-5 内部函数声明

  1. 第四部分    保留源代码

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

图2-6 源代码保留

2.4 本章小结

本章介绍了预处理的概念及作用,在Ubuntu中使用cpp指令对hello.c进行预处理获得hello.i文件,并对hello.i的内容进行了粗浅的介绍,进一步加深了对预处理的印象。

第3章 编译

3.1 编译的概念与作用

  1. 编译的概念

编译就是把高级语言变成计算机可以识别的二进制语言,计算机只认识1和0,编译程序把人们熟悉的语言换成2进制的。

  1. 编译的作用

编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:

  1. 词法分析:

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

  1. 语法分析:

编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位。编译程序的语法规则可用上下文无关文法来刻画。语法分析的方法分为两种:自上而下分析法和自下而上分析法。

  1. 语义检查和中间代码生成:

中间代码是源程序的一种内部表示,或称中间语言。中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码,即为中间语言程序,中间语言的复杂性介于源程序语言和机器语言之间。中间语言有多种形式,常见的有逆波兰记号、四元式、三元式和树。

  1. 代码优化:

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

有两类优化:一类是对语法分析后的中间代码进行优化,它不依赖于具体的计算机;另一类是在生成目标代码时进行的,它在很大程度上依赖于具体的计算机。对于前一类优化,根据它所涉及的程序范围可分为局部优化、循环优化和全局优化三个不同的级别。

  1. 目标代码生成。

目标代码生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。本文特指生成汇编语言代码,须经过汇编程序汇编后,成为可执行的机器语言代码。

编译主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。       

3.2 在Ubuntu下编译的命令

 图3-1 编译命令

生成hello.s文件

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

 图3-2 生成hello.s

3.3 Hello的编译结果解析

  1. 常量

源程序hello.c中有两个字符串常量,“用法:Hello 学号 姓名 秒数!\n”和“Hello %s %s\n”都是printf的格式参数。

图3-3(a) 源文件中第一次调用printf

图3-3(b)源文件中第二次调用printf

他们存储在.rodata节中,位于标号LC0和LC1中。根据UTF-8编码规则,汉字编码为三个字节,在hello.s中以编码形式存在,而英文和其他标点则被编码为一个字节,与ASCII码规则兼容,以原来的形式存在。

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

图3-4 printf格式参数存储位置

argc!=4中的4保存在.text中,作为指令的一部分与-20(%rbp)保存的argc进行比较。

             

图3-5(a)源文件的常量4

图3-5(b)hello.s的常量4

exit(1)中的1指示status,在hello.s文件中保存在%edi寄存器中。

    

图3-6(a) 源文件中常量1    

图3-6(b)hello.s中常量1

循环条件中0和8也保存在text中。

图3-7(a)源文件的常量0和8

图3-7(b)hello.s中常量0

图3-7(c)hello.s中常量8

  1. 变量

全局变量:已经初始化且初始值不为0的全局变量存储在.data节,它们的初始化不需要汇编语句,而是通过虚拟内存请求二进制零的页直接完成。

局部变量:存储在寄存器或者栈中。局部变量i保存在栈%rbp-4位置处。

图3-8(a) 源文件局部变量i

图3-8(b)hello.s局部变量i

算数操作

++操作,在汇编语言中表示为addl $1,-4(%rbp)。-4(%rbp)保存变量i,在自身加1后覆盖原数据。

 

图3-9(a)源文件中++操作

 

 图3-9(b)hello.s中++操作

  1. 关系操作和控制转移

判断参数argc是否等于4

图3-10(a) 源文件关系操作

汇编代码为

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

图3-10(b)hello.s中关系操作

je用于判断cmpl产生的条件码,若两个操作数相等则跳转到标号L2处执行,若不等则从movl指令处继续执行。

  1. 循环操作

 图3-11(a) 源文件循环操作

汇编代码

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

图3-11(b) hello.s循环操作

先赋初值i=0,然后跳转到.L3进行条件判断,jle用于判断cmpl产生的条件码,若不相等调转到.L4执行循环体,i加一后重新判断和执行。至i=7后结束循环。

  1. 数组/指针/结构操作

指针数组char *argc[]

 图3-12(a) 源文件指针数组

argc保存在%edi中,argv首地址保存在%rsi中。

 图3-12(b)hello.s中参数位置

在argv数组中,argv[0]指向输入程序的路径和名称,其他数组元素分别储存字符串。char*类型占8个字节,可推知argv[1]首地址在M[%rbp-32+16]中,argv[2]首地址在M[%rbp-32+8] ,argv[3]首地址在M[%rbp-32+24]中。

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

图3-12(c)hello.s中数组地址

  1. 函数操作

main函数

参数传递:argc保存在%edi中,argv首地址保存在%rsi中。

函数调用:被系统启动函数调用。

函数返回:将%eax设为0后返回。

 图3-13 hello.s中main函数返回

pintf函数

两次调用printf

第一次

图3-14(a)第一次调用printf

汇编代码

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

 图3-14(b)hello.s中第一次调用printf

条件不符合时才调用。现将参数保存在%edi中,然后调用puts输出。

第二次调用

 图3-15(a)第二次调用printf

汇编代码

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

 图3-15(b)hello.s中第二次调用printf

参数传递:传入格式字符串的首地址、argv[1]和argv[2]的首地址。

函数调用:在循环体中执行,循环条件满足时才调用。汇编指令为call printf。

exit函数

 图3-16(a) 源文件调用exit

汇编代码

 图3-16(b)hello.s中调用exit

参数设为1后执行call exit。

atoi函数

 图3-17(a) 源文件调用atoi

汇编代码

 图3-17(b)hello.s中调用atoi

获得argv[3]的首地址,保存到%rdi中,然后执行call atoi。

sleep函数

 图3-18(a) 源文件调用sleep

汇编代码

 图3-18(b)hello.s中调用sleep

atoi的返回值保存在%eax中,先将其保存到%edi中再执行call sleep。

getchar  函数

 图3-19(a)源文件调用getchar

汇编代码

图3-19(b)hello.s中调用getchar

无参数,循环结束后调用call getchar。

3.4 本章小结

在本节中介绍了编译的概念及作用,并在Ubuntu中生成了hello.s文件,通过比较hello.c和hello.s文件,熟悉了数据如何存储,赋值操作、算数操作、关系操作、数组操作、控制转移和数操作等如何用汇编实现和表示。同时加深了对argc和argv使用的理解,更加理解参数传递的规则、寄存器、堆栈的使用。

第4章 汇编

4.1 汇编的概念与作用

  1. 汇编的概念

驱动程序运行汇编器as,将汇编语言的ascii码文件(这里是hello.s)翻译成机器语言的可重定位目标文件(hello.o)的过程称为汇编。

  1. 汇编的作用

汇编将.s汇编程序翻译为机器语言指令,并把这些指令打包生成可重定位目标程序的格式ELF,并将结果保存在.o文件中。

4.2 在Ubuntu下汇编的命令

图4-1 生成hello.o

4.3 可重定位目标elf格式

4.3.1 readelf命令

使用readelf指令生成文本文件

图4-2 readelf命令

图4-3 生成hello.txt文件

4.3.2 分析ELF格式

ELF文件内容有两个平行的视角:一个是程序连接角度,另一个是程序运行角度,如图所示。

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

图4-4 ELF文件格式

ELF header在文件开始处描述了整个文件的组织,Section提供了目标文件的各项信息(如指令、数据、符号表、重定位信息等),Program header table指出怎样创建进程映像,含有每个program header的入口,section header table包含每一个section的入口,给出名字、大小等信息。

  1. ELF头

ELF头包含了系统信息、字节顺序、操作系统、ELF头大小、节大小等信息。

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

图4-5 ELF头

  1. section header

描述每个节的特性,如名称、大小、类型、位置等。

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

图4-6 section headers

  1. 重定位节

各个段引用的外部符号等在链接时需要通过重定位对这些位置的地址进行修改。链接器会通过重定位节的重定位条目计算出正确的地址。

hello.o需重定位:.rodata中的模式串,puts,exit,printf,atoi,sleep,getchar等符号。

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

图4-7 重定位节

  1. 符号表

存放函数和静态变量名,节名称和位置

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

图4-8 符号表

4.4 Hello.o的结果解析

使用objdump -d -r hello.o > dump_hello.txt命令生成反汇编文件

图4-9 hello.o反汇编

分析hello.o的反汇编并与hello.s进行对照分析。

  1. 操作数的表示:hello.s文件里的操作数是十进制数,而反汇编代码中的操作数是用十六进制数表示。

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

 图4-10(a) 反汇编代码十六进制

  1. 在控制转移上:hello.s使用.LC0和.LC1等标号进行跳转,而反汇编代码使用目标代码的虚拟地址跳转。不过目前留下了重定位条目,跳转地址为零。它们将在链接之后被填写正确的位置。
  2. 在函数调用上: hello.s直接call函数名称,而反汇编代码中call的是目标的虚拟地址。但和上一条的情况类似,只有在链接之后才能确定运行执行的地址,目前目的地址是全0,并留下了重定位条目。

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

图4-10(b) 反汇编代码函数调用

4.5 本章小结

本章介绍了汇编的概念及作用,在Ubuntu中使用gcc将hello.s转化为了hello.o可重定位目标文件。使用readelf指令生成了hello.txt文件用于分析ELF文件格式,重点分析了ELF头、节头表、段头表、重定位表和符号表。然后我们使用objdump指令对hello.o进行反汇编并得到了反汇编文件dump_hello.txt,将其与hello.s文件进行对照分析,了解了汇编语言与机器语言的区别。目前程序已经能被机器直接识别了,只差链接就可以让程序运行起来了。

第5章 链接

5.1 链接的概念与作用

  1. 链接的概念

链接是将不同的可重定位目标文件的代码和数据综合在一起,通过符号解析和重定位过程,生成一个可以在程序中加载和运行的单一可执行目标文件的过程。

  1. 链接的作用

可以将公共函数聚合为单个文件,生成共享函数库;使分开编译成为可能,在当需要改动文价时,只需更改一个源文件,编译,然后重新编译,不需要重新编译其他源文件。

5.2 在Ubuntu下链接的命令

命令:

图5-1 ld命令生成hello

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

  1. readelf命令

图5-2 生成ELF格式文件outELF.txt

  1. 分析ELF格式

 ELF文件头

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

图5-3 ELF文件头

节头

 描述每个节的特性,如名称、大小、类型、位置等。

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

图5-4 节头

5.4 hello的虚拟地址空间

使用edb加载hello,data dump窗口可以查看加载到虚拟地址中的hello程序。

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

图5-5 edb查看hello

program headers告诉链接器运行时加载的内容并提供动态链接需要的信息。

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

图5-6 program headers

VirtAddr表示虚拟空间地址。

程序包括PHDR,INTERP,LOAD,DYNAMIC,NOTE,GNU_PROPERTY,GNU_STACK,GNU_RELRO几个部分。

  1. PHDR 保存程序头表。
  2. INTERP 指定在程序已经从可执行文件映射到内存之后,必须调用的解释器。
  3. LOAD 表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等。
  4. DYNAMIC 保存了由动态链接器使用的信息。
  5. NOTE 保存辅助信息。
  6. GNU_STACK:权限标志,用于标志栈是否是可执行。
  7. GNU_RELRO:指定在重定位结束之后哪些内存区域是需要设置只读。

5.5 链接的重定位过程分析

  1. 命令:  objdump -d -r hello > hello_objdump.s

图5-7 生成hello反汇编文件hello_objdump.s

  1. 分析hello和hello.o不同:

a)   新增函数文件

链接加入了hello.c用到的库函数printf、getchar、exit等。

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

图5-8 新增函数文件

b)    新增节:

新增.init和.plt节,以及节中定义的函数。

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

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

图5-9 新增节

c)    函数调用地址与跳转调用地址

链接过程完成了调用函数的重定位,此时hello里的函数调用地址和跳转的地址已经是确切的虚拟地址了。

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

图5-10 调用地址变化

(3)    链接过程:

  1. 符号解析:链接器将每个符号引用和符号定义关联起来。

b)    重定位:将多个单独的代码节和数据节合并为单个节,将符号从它们的.o文件的相对位置重新定位到可执行文件的最终绝对内存位置,更新所有对这些符号的引用来反映它们的新位置。

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

图5-11 重定位示例

c)    具体分析重定位过程:

在链接过程中使用的命令指定了动态链接器为64位的:/lib64/ld-linux-x86-64.so.2,同时添加了crti.o,crt1.o,crtn.o等系统目标文件,将程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中的sleep,printf,exit,getchar等函数以及_start函数中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将这些函数从不同文件中链接生成一个可执行文件。同时链接器根据可重定位目标文件中的重定位表同符号表一一对应,修改重定位信息。

5.6 hello的执行流程

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

地址

名称

401000

<_init>

401020

<.plt>

401090

4010a0

4010b0

4010c0

4010d0

4010e0

4010f0

<_start>

401120

<_dl_relocate_static_pie>

401125

401152

4011a1

40115b

4010a0

4010c0

4010e0

40115b

4010a0

4010c0

4010e0

40115b

4010a0

4010c0

4010e0

40115b

4010a0

4010c0

4010e0

40115b

4010a0

4010c0

4010e0

40115b

4010a0

4010c0

4010e0

40115b

4010a0

4010c0

4010e0

40115b

4010a0

4010c0

4010e0

4010b0

4011c0

<__libc_csu_init>

401230

<  libc_csu_fini>

401238

<_fini>

5.7 Hello的动态链接分析

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

在ELF文件中能找到:

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

图5-12 ELF中节头

利用代码段和数据段的相对位置不变的原则计算变量的正确地址。而对于库函数,需要plt、got的协作。

通过edb的datadump窗口定位到GOT表处,在dl_init前后设置断点,run this line观察GOT表的变化。

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

图5-13 GOT表

plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。plt就能跳转到正确的区域。

5.8 本章小结

本章研究了链接的过程。通过edb查看hello的虚拟地址空间,对比hello与hello.o的反汇编代码,深入研究了链接的过程中重定位的过程。

第6章 hello进程管理

6.1 进程的概念与作用

  1. 进程的概念

一个执行中程序的实例。

  1. 进程的作用

进程给应用程序提供两个关键抽象:一是独立的逻辑控制流,每个程序似乎独占CPU,这是通过OS内核中的上下文切换机制实现;二是私有地址空间,每个程序似乎独占内存系统,这是由OS内核中的虚拟内存机制实现。

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

Shell是用户级的应用程序,代表用户控制操作系统中的任务。处理流程如下:

① 在shell命令行中输入命令:$./hello

② shell命令行解释器构造argv和envp;

③ 调用fork()函数创建子进程,其地址空间与shell父进程完全相同,包括只读代码段、读写数据段、堆及用户栈等

④ 调用execve()函数在当前进程(新创建的子进程)的上下文中加载并运行hello程序。将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间

⑤ 调用hello程序的main()函数,hello程序开始在一个进程的上下文中运行。

6.3 Hello的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过程

int execve(char *filename, char *argv[], char *envp[])

参数说明:

filename:可执行文件

目标文件或脚本(用#!指明解释器,如 #!/bin/bash)

argv:参数列表,惯例:argv[0]==filename

envp[]:最后一个参数envp指定了新程序的环境列表。参数envp对应于新程序的environ数组。

调用过程:

  1. execve系统调用陷入内核,并传入命令行参数和shell上下文环境
  2. execve陷入内核的第一个函数:do_execve,do_execve封装命令行参数和shell上下文
  3. do_execve调用do_execve_common,do_execve_common打开ELF文件并把所有的信息一股脑的装入linux_binprm结构体
  4. do_execve_common中调用search_binary_handler,寻找解析ELF文件的函数
  5. search_binary_handler找到ELF文件解析函数load_elf_binary
  6. load_elf_binary解析ELF文件,把ELF文件装入内存,修改进程的用户态堆栈(主要是把命令行参数和shell上下文加入到用户态堆栈),修改进程的数据段代码段
  7. load_elf_binary调用start_thread修改进程内核堆栈(特别是内核堆栈的ip指针)
  8. 进程从execve返回到用户态后ip指向ELF文件的main函数地址,用户态堆栈中包含了命令行参数和shell上下文环境

6.5 Hello的进程执行

6.5.1 逻辑控制流和时间片

进程的运行本质上是CPU不断从程序计数器 PC 指示的地址处取出指令并执行,值的序列叫做逻辑控制流。操作系统会对进程的运行进行调度,执行进程A->上下文切换->执行进程B->上下文切换->执行进程A->… 如此循环往复。 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。在一个程序被调运行开始到被另一个进程打断,中间的时间就是运行的时间片。

6.5.2 用户模式和内核模式

用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据。

内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。

6.5.3 上下文

上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。

6.5.4 调度的过程

在对进程进行调度的过程,操作系统主要做了两件事:加载保存的寄存器,切换虚拟地址空间。

6.5.5 用户态与核心态转换

为了能让处理器安全运行,需要限制应用程序可执行指令所能访问的地址范围。因此划分了用户态与核心态。

核心态可以说是拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证了系统的安全性。

6.6 hello的异常与信号处理

6.6.1正常运行状态

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

图6-1 正常运行状态

6.6.2异常类型与处理方式

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

图6-2 异常

6.6.3信号

  1. 按回车:没有影响,程序继续执行。

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

图6-3 按回车的影响

  1. Ctrl-Z:进程收到信号SIGSTP,进程挂起。ps查看进程的PID,发现hello的PID是8804,用jobs查看此时hello后台的job号为1,调用fg将其调回前台将继续执行。

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

图6-4 输入Ctrl-Z、ps、jobs、pstree、fg、kill的结果

  1. Ctrl-C:进程收到SIGINT信号,结束hello。用ps查询不到PID,jobs中也无显示,可知hello已被彻底终止。

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

图6-5(a) 输入Ctrl-C、ps、jobs命令

图6-5(b) fg命令

  1. 乱按:输入的字符缓存到缓冲区中,没有影响。

程序人生-Hello’s P2P_第36张图片图6-6 乱按结果

  1. kill命令:挂起的进程被终止,ps无法查到其PID。

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

图6-7 kill命令

6.7本章小结

本章了解了进程的创建及执行,分析了异常和信号的处理机制,对进程管理有了深入的理解。

第7章 hello的存储管理

7.1 hello的存储器地址空间

7.1.1 逻辑地址

逻辑地址(Logical Address)是指由程序产生的与段相关的偏移地址部分。在这里指的是hello.o中的内容。

7.1.2 线性地址

线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生段中的偏移地址,加上相应段的基地址就生成了一个线性地址。

7.1.3 虚拟地址

CPU启动保护模式后,程序hello运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。

7.1.4 物理地址

放在寻址总线上的地址。放在寻址总线上,如果是读,电路根据这个地址每位的值就将相应地址的物理内存中的数据放到数据总线中传输。如果是写,电路根据这个地址每位的值就在相应地址的物理内存中放入数据总线上的内容。物理内存是以字节(8位)为单位编址的。

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

在 Intel 平台下,逻辑地址(logical address)是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address。我们把这个过程称作段式内存管理。

一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。

全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。

给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。

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

页式管理将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后将页式虚拟地址与内存地址之间建立一个一一对应的页表,并用相应的硬件地址变换机构(MMU等),来解决地址变换(翻译)问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。

操作:在获得线性地址(虚拟地址)后,我们将这个地址分成VPN和VPO,VPN表示虚拟页号,VPO表示虚拟页偏移量,我们可以通过VPN来获得PPN(物理页号),具体如下:在TLB(翻译后备缓冲器)中,将VPN分为TLBI,TLBT来寻找所求的物理页号;若不在TLB中,则去缓存中的或内存中的页表中寻找,若缺页,MMU触发一次异常,更新页表。最终将取得的PPN与VPO组合得到我们要的物理地址。

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

当我们在TLB中找不到我们要的PPN时,我们需要在页表中寻找。我们知道CR3寄存器始终指向一级页表,因此在四级页表中我们将VPN拆成4部分,在四级页表中从一级二级三级的在这三级页表目录中寻找我们的页表的地址,然后在四级的页表中,我们找到我们要的PPN,然后与VPO组合,得到PA。

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

在获得了PA之后,我们需要用这个地址去缓存中寻找我们要的数据。

首先,我们将PA拆分成CT,CI,CO,这三个分别表示标记,组索引,块内偏移量。

我们先通过组索引来确定我们的组,然后在这个组中找有效位为1的而且有对应标记的缓存行,若找到,则用块内偏移量锁定我们要的数据块,如果找不到,则到第二级(下一级)cache中去寻找,以此类推。

7.6 hello进程fork时的内存映射

mm_struct(内存描述符):描述了一个进程的整个虚拟内存空间。

vm_area_struct(区域结构描述符):描述了进程的虚拟内存空间的一个区间。

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

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。

7.7 hello进程execve时的内存映射

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

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

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

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

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

页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的:

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

图7-1 缺页中断处理

  1. 处理器生成一个虚拟地址,并将它传送给MMU
  2. MMU生成PTE地址,并从高速缓存/主存请求得到它
  3. 高速缓存/主存向MMU返回PTE
  4. PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
  5. 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
  6. 缺页处理程序页面调入新的页面,并更新内存中的PTE
  7. 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。

7.9动态存储分配管理

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

分配器将堆视为一组不同大小的块 (blocks)的集合来维护,每个块要么是已分配的,要么是空闲的。分配器主要分为显式分配器和隐式分配器。

策略:显式空闲链表和隐式空闲链表。

隐式空闲链表:通过头部的大小字段隐式的连接。分配器可以通过遍历堆中的所有块,从而间接地遍历整个空闲块地集合。可以通过添加脚部的方式实现隐式双向链表。寻找空闲块时可使用首次适配、下一次适配、最佳适配和分离适配等分配策略;分配空闲块时,如果块较大且找不到更合适的,则可以进行分割;释放块时需要按照四种情况合并相邻空闲块。

显式空闲链表:通过某种数据结构来管理、分配空闲块,而不去管理已分配的块。

7.10本章小结

本章介绍了存储器地址空间、段式管理、页式管理,VA 到 PA 的变换、物理内存访问, hello 进程fork时和execve 时的内存映射、缺页故障与缺页中断处理、包括隐式空闲链表和显式空闲链表的动态存储分配管理。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

文件类型如下:

  1. 普通文件:包含任意数据的文件。
  2. 目录(directory):包含一组链接的文件,每个链接都将一个文件名映射到一个文件
  3. 套接字(socket):用来与另一个进程进行跨网络通信的文件
  4. 命名通道
  5. 符号链接
  6. 字符和块设备

设备管理:unix io接口

  1. 打开和关闭文件
  2. 读取和写入文件
  3. 改变当前文件的位置

8.2 简述Unix IO接口及其函数

  1. open()函数:这个函数会打开一个已经存在的文件或者创建一个新的文件。
  2. close()函数:这个函数会关闭一个打开的文件。
  3. read()函数:这个函数会从当前文件位置复制字节到内存位置。
  4. write()函数:这个函数从内存复制字节到当前文件位置。
  5. lseek()函数:改变文件位置。
  6. access()函数:判断文件是否具有读、写、可执行权限,或者是否存在。
  7. dup或dup2()函数:创建一个文件描述符,其指向同一个文件表。

8.3 printf的实现分析

[转]printf 函数实现的深入剖析 - Pianistx - 博客园

printf函数:

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

 

图8-1 printf函数

vsprintf 函数:

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

 

图8-2 vsprintf函数

vsprintf函数将所有的参数内容格式化之后存入buf,返回格式化数组的长度。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

当程序调用getchar时,程序等待用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车(回车也在缓冲区中)。

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

异步异常-键盘中断的处理:当用户按键时触发键盘终端,操作系统将控制转移到键盘中断处理子程序,中断处理程序执行,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区,显示在用户输入的终端内。当中断处理程序执行完毕后,返回到下一条指令运行。

8.5本章小结

本章介绍了linux的IO设备管理方法、Unix的IO接口及其函数、printf函数的实现和getchar函数的实现。

结论

用计算机系统的语言,逐条总结hello所经历的过程。

  1. hello.c预处理到hello.i文本文件
  2. hello.i编译到hello.s汇编文件
  3. hello.s汇编到二进制可重定位目标文件hello.o
  4. hello.o链接生成可执行文件hello
  5. bash进程调用fork函数,生成子进程;
  6. execve函数加载运行当前进程的上下文中加载并运行新程序hello
  7. hello的运行需要地址的概念,虚拟地址是计算机系统最伟大的抽象。
  8. hello的输入输出与外界交互,与linux I/O息息相关
  9. hello最终被shell父进程回收,内核会收回为其创建的所有信息

计算机系统是一个庞大复杂且由软硬件相互协调工作的系统。原来看似简单的几行代码,实则需要许多过程才能让它真正运行起来。通过对hello程序的整个生命周期进行分析,充分理解到计算机系统的各个部分是如何协调工作的,程序运行不仅需要宏观上的把控,还需要抽象的、底层的配合。如今只是看到了计算机系统的冰山一角,还需要更加深入的研究才能真正学明白。

计算机科学发展迅速,这要求我们不仅要学习以前的知识,还要跟上时代的步伐,洞悉计算机的前景和机遇,紧跟国家战略安排,提高创新意识与创新能力,不局限于已有的计算机系统实现方式,多学多思多实践。

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

 

图9:hello的一生

附件

文件名称

文件作用

hello.c

源文件

hello.i

经预处理后的文件

hello.s

编译之后的汇编文件

hello.o

汇编之后的可重定位目标文件

hello.txt

hello.o的ELF格式文件

dump_hello.txt

hello.o的反汇编文件

hello

链接之后的可执行目标文件

outELF.txt

hello的ELF格式文件

hello_objdump.s

hello反汇编文件

参考文献

[1]  Randal E.Bryant / David O’Hallaron.深入理解计算机系统(第三版).机器工业出版社,2016-11

[2]  预处理_百度百科 (baidu.com)

[3]   (133条消息) c语言中argc和argv[ ]的作用及用法_fxfreefly的博客-CSDN博客_c语言argc和argv怎么使用

[4]  (133条消息) C语言atoi函数_C语言技术网的博客-CSDN博客_c语言atoi函数头文件

[5]  (134条消息) fork()函数详解以及进程创建的过程_one-77的博客-CSDN博客_(1) 系统调用fork()是如何创建进程的?

[6]  (134条消息) 5.execve()到底干了啥?_chengonghao的博客-CSDN博客_execve

[7]  Linux下的文件I/O编程 | 《Linux就该这么学》 (linuxprobe.com)

你可能感兴趣的:(p2p,ubuntu,网络协议)