Hello的一生

摘 要

本文遍历了hello.c在Linux下生命周期,借助Linux下系列开发工具,通过对其预处理、编译、汇编等过程的分步解读及对比来学习各个过程在Linux下实现机制及原因。同时通过对hello在Shell中的动态链接、进程运行、内存管理、I/O管理等过程的探索来更深层次的理解Linux系统下的动态链接机制、存储层次结构、异常控制流、虚拟内存及UnixI/O等相关内容。旨在将课本知识与实例结合学习,更加深入地理解计算机系统的课程内容。

关键词:计算机系统;编译;虚拟内存;进程管理;I/O设备管理

第1章 概述

1.1 Hello简介
P2P(Program to Process,从程序到进程)的过程:

  1. 预处理
    在Hello的一个完整的编译过程中,第一步是预处理代码。
    在预处理阶段,编译器驱动程序(在本次实验中是GCC)首先调用C预处理器(cpp),读取输入的hello.c源文件,进行预处理。预处理器删除代码的所有注释,并识别所有以#开头的行,进行相应的操作,例如:
    a) 将#include宏所指示文件内容插入到这一行。
    b) 将#define A B宏定义语句之后的所有A简单地替换为B。如果A是形如A(x)形式的宏函数,则用x位置的表达式(简单地)替换x。
    c) 检查#ifdef A、#ifndef A所引用的宏A是否定义了,根据A事实上是否被定义过,来决定是否保留这个if块中的语句。
    预处理阶段结束时,预处理器将生成中间文件hello.i,供编译阶段使用。
  2. 编译
    在编译阶段,C编译器(ccl)读取文件hello.i,将其翻译为指定平台的汇编代码hello.s。
  3. 汇编
    在汇编阶段,汇编器(as)将汇编代码文件hello.s翻译为机器代码,保存为可重定位目标文件hello.o。
  4. 链接
    在链接阶段,链接器(ld)将收集程序使用的所有可重定位目标文件,进行组合、重定位,生成一个可执行目标文件。在本次实验中,有hello.o、printf.o,后者是C标准库提供的printf函数的实现,这个函数与一些其他函数一起,被存在一个后缀为.a的存档文件中。最后获得的可执行文件可以被加载到内存中的适当位置并直接执行,无需再做修改。
  5. 执行
    在Shell中键入可执行文件的路径:./hello。按下回车后,Shell判断到这是一个外部的可执行文件,所以它调用fork()生成一个自身的子进程,然后在子进程中调用execve(),,内核将清除现有进程(即子进程)所有的用户区域、将新进程(hello的一个实例)的私有区域(数据、代码、bss、栈)映射到hello文件中的特定区域(bss和栈、堆区域是请求二进制零的,映射到匿名文件)。这些段并没有被立刻加载到内存中。直到程序第一次访问时,内核会通过缺页中断处理子程序,将所需内存的页面换入物理内存。接着,内核映射进程的共享区域,将程序使用到的动态链接对象映射到用户虚拟地址空间的共享区域。最后,execve设置当前进程上下文中程序计数器RIP的值为代码的入口函数。这个入口函数会在一些必要的处理(如运行时动态链接)后调用main()函数,进而执行我们为hello编写的代码。
    020(Zero to Zero,从零到零)的过程:
    首先,Shell调用fork()函数,内核创建一个当前Shell进程的副本,操作系统为新进程创建一个进程结构体、划分内存空间、创建虚拟内存映射、根据优先级分配CPU时间片,新进程开始在CPU上执行。接着,Shell调用execve()函数,这个函数通过系统调用陷入内核。内核在Shell子进程的上下文中清除旧的私有区域,并将hello的私有区域映射到当前进程用户虚拟地址空间的特定区域。内核创建打开文件描述符、映射共享内存区域、设置RIP的值为入口函数的第一条指令的地址,入口函数引导程序进入main()函数,执行用户代码。用户代码中printf()函数调用系统调用write()、通过后者陷入内核,内核将字符串写入标准输出流stdout并刷新。随后,流中的新字符被写入虚拟内存中映射的显示内存,图形适配器将新字符写入图形缓冲区、以特定的刷新率输出到显示器,用户可以看到程序的输出。hello执行完成后,在main函数中返回。hello执行完毕,就进入已终止的状态。然后,Shell得知hello运行完毕,回收它在内存中的数据,内核给父进程(Shell)发送一个SIGCHLD信号,同时将hello在系统中的痕迹删除。至此,hello的一次运行完毕。
    1.2 环境与工具
    1.2.1 硬件环境
    Intel® Core™ i7-7700HQ CPU @2.80GHz
    8.00GB RAM
    1TB Disk
    1.2.2 软件环境
    64位Windows 10 家庭中文版
    ubuntu-18.04.3-desktop-amd64
    1.2.3 开发工具
    Visual Studio Code 1.38
    Vim
    Edb-debugger
    1.3 中间结果
    文件名称 作用
    hello.i hello.c 源文件预处理之后得到的修改过后的文本文件
    hello.s hello.i 经过编译之后得到的汇编程序
    hello.o hello.s 经过汇编之后得到的可重定位的二进制文件
    hello hello.o 经过链接得到的可执行文件
    hello.elf hello.o 的 elf 文件
    hello.asm hello.o 的反汇编代码文件
    hello2.elf hello 的 elf 文件
    hello2.asm hello 的反汇编代码文件
    1.4 本章小结
    磨刀不误砍柴工。
    总的列了一下这整个实验中的中间结果和所在的软硬件环境还有使用的工具。

第2章 预处理

2.1 预处理的概念与作用

  1. 预处理的概念
    预处理一般是指由预处理器对程序源代码文本在编译之前进行的处理。预处理工作也叫作宏展开。预处理过程中,预处理器通过执行以#开头的命令、删除注释等工作来修改.c 文件为.i 文件。
  2. 预处理的作用
    根据以字符#开头的命令,修改原始的 C 程序,删除所有的注释。使源程序成为中间码之后,再进行编译工作。详细情况如下:
    A.宏定义
    (1)不带参数的宏定义
    宏定义又叫做宏代换、宏替换,简称“宏”。将宏名替换为字符串,也就是在对相关命令或者语句的含义和功能做具体分析之前就要替换。例如:#define PI 3.14159 在预处理阶段将程序中出现的所有 PI 都替换为 3.14159
    需要注意的问题有:
    1.宏名一般都要大写。
    2.使用宏可以提高程序的通用性和易读性,减少不一致性,减少输入错误和便于修改。
    3.预处理是在编译之前的处理,不做语法检查,编译工作的任务之一是语法检查。
    4.宏定义的结尾不加分号。
    5.宏定义卸载函数的花括号外面,作用域是其后面的程序,通常在文件的最开头。
    6.可以用#undef 来终止宏定义的作用域。
    7.宏定义是允许嵌套的。
    8.字符串(“”)中永远不包含宏。
    9.宏定义不分配内存,变量定义分配内存。
    10.宏定义不存在类型问题,它的参数也是无类型的。
    (2)带参数的宏定义
    例如:#define SUB(a, b) a - b 在程序中出现 result = SUB(2,3)则被替换为 result= 2 - 3。
    需要注意的问题有:
    1.实参如果是表达式是容易出问题的。
    2.宏名和参数的括号间不能有空格。
    3.宏替换只做替换,不做计算,不做表达式求解。
    4.函数调用在编译后程序运行时进行,并且分配内存。宏替换在编译前进行,不分配内存。
    5.红的哑实结合不存在类型,也米有类型转换。
    6.宏展开使程序变长,函数调用不会。
    7.宏展开不占运行时间,只占用编译时间,函数调用会占用运行时间。
    B.文件包含
    文件包含处理是指在一个源文件中,通过文件包含命令将另一个源文件的内容全部包含在此文件中。在源文件编译时,连同被包含进来的文件一同编译,生成目标文件。
    例如:#include<stdio.h>命令告诉预处理器读取系统头文件 stdio.h 中的内容,并直接将它插入程序文本中包含语句处。
    C.条件编译
    程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。条件编译指令将决定哪些代码被编译,哪些代码不被编译。可以根据表达式的值或者是某个特定的宏是否被定义来确定编译条件。例如:
    #ifdef 标识符
    程序段 1
    #else
    程序段 2
    只有当标识符已经定义时,程序段 1 才参加编译,否则,编译程序段 2
    2.2在Ubuntu下预处理的命令
    使用命令 gcc -E hello.c -o hello.i 调用gcc编译器套件,让gcc调用预处理器,生成预处理文件hello.i

2.3 Hello的预处理结果解析
我们可以发现,其实main函数里面的内容都没太大的变化,变化的是三个头文件,他们直接变成了那三个头文件的源码。虽然长度变长了许多,但是总的来说并不影响程序的可读性(毕竟现在仍然还是c语言的语法)。
而且预处理之后的程序中充斥着类似的:

用来描述使用的运行库在计算机中的位置。
还有这些:

用来声明可能使用到的函数的名字。
预处理操作把这些东西全都塞到了hello.i文本中。不过这样子也的确方便了编译器对他进行翻译成汇编语言的操作。
2.4 本章小结
本章节简单介绍了c语言在编译前的预处理过程,简单介绍了预处理过程的概念和作用,对预处理过程进行演示,并举例说明预处理的结果还有解析预处理的过程。

第3章 编译

3.1 编译的概念与作用
编译编译,光听这两个字就知道他的概念了,他是一个将预处理好的高级语言程序文本翻译成能执行相同操作的汇编语言的过程。他的作用很简单,由于读懂高级语言对于计算机来说实在太过困难,所以需要一个编译器将高级语言直接转换成更接近机器语言的汇编语言,使得将高级语言转换成计算机可执行的二进制文件这个操作更加方便。
3.2 在Ubuntu下编译的命令
使用指令 gcc -S hello.i -o hello.s 来生成hello.i对应的汇编代码,以ASCII码格式保存在hello.s文件中。

3.3 Hello的编译结果解析

他只有六十多行,跟三千多行的hello.i比起来真的是小巫见大巫了。认真观察hello.s文本,我们可以发现他其实只翻译了hello.i中main函数及其周围的部分,头文件里面的内容他并没有翻译(难怪文本那么少)。实际上他其实也只需要翻译这个部分,要引用的头文件部分如何链接形成可执行文件在第四和第五章中会有解释。
3.3.1对于变量的处理
对于全局变量:
源程序hello.c中有一个初始化过的全局变量sleepsecs,

这个变量是真的有毒,我们给他赋的初值是浮点数2.5,但是他是个整型变量,我们仔细观察hello.s,发现在编译时被隐式转化为了整数2,而实际运行时时间间隔也的确是2秒。

可以看出sleepsecs在.data段,大小和对齐方式都为4个字节,值为2。
对于局部变量:
hello.c中也有一个局部变量i:

很显然我们在hello.s的开头声明中找不到他,那么他在哪儿呢???
认真看看下面两张图:

原来这个变量i在用到他的时候才会出现啊……而且作为一个毫无人权的局部变量,他只会被存放在一个内存空间中(例子中是存放在-4(%rbp)这个位置)
对于字符串:
汇编语言中,输出字符串作为全局变量保存,因此存储于.rodata节中。汇编文件hello.s中,共有两个字符串,均作为printf参数,分别为:

对应hello.c中的这两个字符串。

对于数组:
取数组的第i位一般是按照取数组头指针加上第i位的偏移量来操作,例如hello.c中的argv[1]和argv[2]:

Argv的首地址即数组头指针存储在-32(%rbp), argv[2]和argv[1]的偏移量分别为16和8.

3.3.2 对于关系操作符与控制语句的处理
在hello.c中出现了!=关系操作符(如下),

对于这个控制语句,编译器是转换成如下语句的:

我们从cmpl那儿开始分析,cmpl是一个比较语句,你可以将他与接下来的je放在一起看。假设je address前面有一个语句cmpl x,y ,则je判断的值是x-y的值,如果x-y的值等于0(即判断x是否等于y),则je条件达成,那么跳转到address位置,从address位置开始继续往下执行。否则的话则从je的下一条语句开始继续往下执行。
3.3.3对于函数的操作
返回值:一个函数的返回值一般存在寄存器eax中,如果要设定返回值的话,那就先将返回值传入eax,然后再用ret语句返回。以hello.c为例子,具体操作如下:
(return 0)
函数调用及参数传递:如果你需要调用一个函数并且向其中传入参数的话,你需要先找几个寄存器,将参数传给这些寄存器,选择哪些寄存器要看你调用的函数的具体实现,然后再执行一个call跳转语句,跳转到你想要调用的函数的开头位置,此时程序就会从那个位置开始继续执行。下面是几个例子:

printf(“Usage: Hello 学号 姓名!\n”); 被简化为puts(“Usage: Hello 学号 姓名!\n”);第一个参数为字符串的首地址,存在%rdi中。

exit(1); 第一个参数1被存在%edi中。

printf(“Hello %s %s\n”,argv[1],argv[2]); 的三个参数分别被存在%rdi, %rax, %rdx中。

3.4 本章小结
本章描述了编译器依据hello.c C源程序生成的汇编码中,hello程序的数据类型、赋值、函数调用、参数传递、返回值是如何实现的。
编译是从C语言程序生成可执行文件的第二步,它将预处理后的C语言代码转换为与机器指令一一对应的、人类可读的汇编代码。在计算机被发明的早期,为了给计算机编程,人们需要直接书写机器代码,后来才有了这种更便于人类阅读的汇编代码——它事实上是机器代码的另一种书写形式。如果要运行一个编译型高级语言程序,必须将其代码转换为机器代码,汇编代码就是一个中间形式。随着时间的推移,编程语言也变得更加复杂,但是编译仍然是整个流程中不可缺少的一个重要环节。编译后的程序离变成机器代码只有一步之遥——汇编。

第4章 汇编

4.1 汇编的概念与作用
汇编的概念是指的将汇编语言(xxx.s)翻译成机器指令,并将这些指令打包成一种叫做可重定位目标程序,并将这个结果保留在(xxx.o)中。这里的xxx.o是二进制文件。汇编过程的作用是将汇编指令转换成一条条机器可以直接读取分析的机器指令。
4.2 在Ubuntu下汇编的命令
使用 as hello.s -o hello.o 命令汇编hello.s文件,并将结果保存在hello.o文件中。

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

ELF Header::用于总的描述ELF文件各个信息的段。

Section Header:描述了.o文件中出现的各个节的类型、位置、所占空间大小等信息

.rela.text:重定位节,这个节包含了.text(具体指令)节中需要进行重定位的信息。这些信息描述的位置,在由.o文件生成可执行文件的时候需要被修改(重定位)。在这个hello.o里面需要被重定位的有printf , puts , exit , sleepsecs , getchar , sleep ,rodata里面的两个元素(.L0和.L1字符串)

.rela.eh_frame是eh_frame节的重定位信息。

符号表存放了在程序中定义和引用的函数和全局变量的信息。这些信息包括大小、类型、名字等。如果程序引用了一个自身代码未定义的符号,则称之为未定义符号,这类引用必须在静态连接期间用其他目标模块或者库解决,或者在加载是通过动态连接解决

4.4 Hello.o的结果解析

  1. 机器语言的构成,机器代码由指令码和操作码组成,分别指示指令的类型和操作的类型。
  2. 机器语言与汇编语言的映射关系
    a) 每个机器代码都对应着一个汇编代码,然而有些机器代码可以写成多种汇编指令,比如je和jz。有些指令对于不同寄存器有不同的机器代码,如19,24,26行的mov指令。
    b) 立即数:立即数在汇编语言中以C语言格式书写的数字,前面加上一个$组成。而在机器语言中,立即数被表示为补码形式或者无符号数形式,并以目的机器的字节序存储。立即数被解读为有符号数(补码)还是无符号数(无符号编码)由具体指令决定,在内存中无法区分。
    c) 在分支转移、函数调用类的指令中,目的地址常常以相对当前RIP的偏移量表示。编译后的汇编代码中填入了具体的目的地址,而编译前这些地址是以标签的形式表示的,如第30行、第37行的.L2、.L3。
    d) 参数和局部变量:调用参数存储在上一个函数的栈帧内,或者存储在寄存器中。局部变量存储在作用域函数的栈帧中,以相对于ebp(栈帧)的方式访问。
    e) 函数调用:汇编前,call指令的参数为调用的函数名,而机器代码中,call指令的参数被替换为调用的函数地址。
    4.5 本章小结
    汇编器将汇编代码翻译为机器代码,以目标文件的形式保存。同时由于链接的需要,目标文件是可重定位的,其中保留了一些需要重定位的内容,并在文件中的一个特殊的节:.rel.*重定位节内,指出了所有需要重定位的条目位置。汇编是承前启后的一步,用户代码在这一步后完全变为了二进制格式,数字型常数均被转换为补码、无符号数、IEEE.754格式的二进制格式,程序被进一步翻译为二进制形式。经过接下来的链接之后,目标文件将变为可以直接执行的格式。

第5章 链接

5.1 链接的概念与作用
概念:链接是将代码和数据片段组合成为一个单一文件的过程,这个文件可被直接加载到内存中执行。在现代系统中,链接是由链接器自动执行的。
作用:链接使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
使用命令ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
将hello.o与相关库函数动态链接为hello可执行文件,并且不生成位置无关代码,如图

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

使用命令 readelf -a hello 调用readelf工具查看链接后hello的可执行文件结构信息,如下图。
列出了hello文件的头部信息及各节的信息,可知hello共有25节。从表中还可读出各节的名称、类型、起始偏移量、大小、标志、对齐字节数、内存映像中的地址等信息。

5.4 hello的虚拟地址空间
我们用edb打开hello,可以在Data Dump窗口看见hello加载到虚拟地址中的状况:

可以看出程序是在0x00400000地址开始加载的。
再来分析分析elf里面的Program Headers:
PHDR:程序头表
INTERP:程序执行前需要调用的解释器
LOAD:程序目标代码和常量信息
DYNAMIC:动态链接器所使用的信息
NOTE::辅助信息
GNU_EH_FRAME:保存异常信息
GNU_STACK:使用系统栈所需要的权限信息
GNU_RELRO:保存在重定位之后只读信息的位置
其余的从.dynamic到.strtab节的内容是存放在0x00400fff后面

5.5 链接的重定位过程分析
分析一下比hello.o多出来的这些节头:

.interp:保存ld.so的路径
.note.ABI-tag
.note.gnu.build-i:编译信息表
.gnu.hash:gnu的扩展符号hash表
.dynsym:动态符号表
.dynstr:动态符号表中的符号名称
.gnu.version:符号版本
.gnu.version_r:符号引用版本
.rela.dyn:动态重定位表
.rela.plt:.plt节的重定位条目
.init:程序初始化
.plt:动态链接表
.fini:程序终止时需要的执行的指令
.eh_frame:程序执行错误时的指令
.dynamic:存放被ld.so使用的动态链接信息
.got:存放程序中变量全局偏移量
.got.plt:存放程序中函数的全局偏移量
.data:初始化过的全局变量或者声明过的函数

接下来分析一下汇编程序。
将hello反汇编之后的结果存入hello2.asm

我们可以发现,hello.asm从.text节开始,而hello2.asm从.init节开始

在hello2.asm中导入了诸如puts、printf、getchar、sleep等在hello程序中使用的函数,而这些函数的在hello.asm中就没有出现

我们可以发现,在hello.asm中调用函数都是使用call+<main+偏移量的做法>,而在hello2.asm是直接使用call+<函数名> 的方法来直接调用的。
5.6 hello的执行流程
ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
libc-2.27.so!__libc_start_main
-libc-2.27.so!__cxa_atexit
-libc-2.27.so!__libc_csu_init
hello!_init
libc-2.27.so!_setjmp
-libc-2.27.so!_sigsetjmp
–libc-2.27.so!__sigjmp_save
hello!main
hello!puts@plt
hello!exit@plt
ld-2.27.so!_dl_runtime_resolve_xsave
-ld-2.27.so!_dl_fixup
–ld-2.27.so!_dl_lookup_symbol_x
libc-2.27.so!exit

5.7 Hello的动态链接分析
程序调用一个由共享库定义的函数时,无法准确知道这个函数的运行时地址,因为它所在的共享模块可以在运行时加载到内存的任意位置。GNU 编译系统使用延时绑定的技术解决这个问题,将过程地址的延迟绑定推迟到第一次调用该过程时。延迟绑定需要用到全局偏移量表和过程链接表两个数据结构。一个模块只要调用共享库中的函数,那么该模块就有自己的全局偏移量表和过程链表。其中,全局偏移量表是一个数组,其中的每个条目都是 16 字节。PLT[0]是一个特殊的条目,跳转到动态链接器中。每个条目负责调用一个具体的函数。PLT[1]调用一个系统启动函数,从 PLT[2]开始,每一个条目都负责调用用户代码调用的函数。过程链表是一个数组,其中每个条目是8字节地址。和全局偏移量表联合使用时,GOT[0]和 GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在 ld-linux.so 模块中的入口点。其余的诶个条目对应一个被调用的函数,其地址在运行时被解析。
在本程序中的体现:
如图所示,GOT 的起始位置是 0x601000.

使用 edb 查看函数调用_dl_start 之前 GOT 的内容。可以看到有 16 字节全都是 0,如图所示。

当函数执行到main之前,GOT变成下图所示。

可以看到GOT[1]等已经被重定向到对应函数的地址。
5.8 本章小结
本章深入hello程序运行的汇编级细节,发现了许多表面上不曾观察到的惊人细节:hello程序可谓是麻雀虽小五脏俱全,它的内存映像具有与大型程序一样的格式:有ELF头、有数据段、代码段,段内还有存放全局变量和GOT的.data节、存放代码的.text节、存放PLT的.plt节……在程序加载时,加载器需要调用动态链接器ld-linux.so(我们这里是ld-2.30.so),动态链接器加载hello将要用到的(几乎)所有动态链接库,调用libc_start_main函数,最终调用我们C语言里编写的main函数。main函数调用的puts函数,实际上是puts@plt,后者通过PLT进行跳转,完成puts函数的动态符号解析。PLT和GOT的分工合作完成了相应的符号解析。相应GOT条目(GOT[2])没有被初始化,又跳转到初始化代码,动态解析puts的GOT条目,将真正的puts函数的入口地址写入GOT。main函数返回后,libc_start_main函数进行收尾工作,最终调用_exit函数来释放资源,hello才完成了它的一次运行。

第6章 hello进程管理

6.1 进程的概念与作用
概念:进程提供了一个计算机的抽象。进程就是程序的一个实例,这个实例拥有独立的虚拟地址空间,独立的时间片,对于进程自身来说,它好像独占整个CPU、独占整个内存地址空间。这样的抽象是由操作系统内核的进程管理模块实现的。
作用:进程给程序开发提供了一个简单的模型,允许在一个CPU上同时运行多个程序,并将这些程序的数据隔离成独立的虚拟地址空间,减小了程序因冲突而出错的机率,为动态链接提供了一个平台,开发人员无需考虑其他程序对本程序的影响,因为进程的虚拟内存之间相互隔离。
6.2 简述壳Shell-bash的作用与处理流程
作用:Bash是一种Linux Shell,提供了一个字符型人机交互界面(CLI),允许用户借助Shell运行应用程序或者调用Shell的内置命令。
处理流程:
a) Shell从标准输入读取字符串,获取用户输入的命令
b) 判断用户输入的命令是否为内置命令,如果是,跳到c)执行,否则,跳到d)执行
c) 解析内置命令,并执行
d) 将用户输入的命令视作外部可执行文件,判断该文件是否存在。如果不存在,报错;否则继续执行e)
e) 调用fork系统函数,创建一份Shell进程的子进程。在子进程中,调用execve,将指定的可执行文件加载到本进程的虚拟地址空间、替换子Shell进程执行。
f) 父进程Shell判断用户是否要将其置为后台进程(结尾是否有&符),如果是则跳到a),继续请求下一条输入;否则等待此进程结束。在等待过程中,如果子进程的运行状态改变(如:进入后台、暂停、被中断执行),向标准输出写入提示信息。当此进程正常退出时,释放它的资源,跳到a),请求下一条输入。
6.3 Hello的fork进程创建过程
当Shell判断hello不是一个内置命令、并且它确实存在与磁盘上时,它首先会创建一个自身进程的副本作为子进程,这个操作依靠fork实现。fork创建一个与自身进程几乎完全一样的副本子进程,子进程的虚拟地址空间与父进程完全一致,两者共享一份共享内存页面,私有页面被标记为写时复制,只有当子进程对其修改时才真正在内存中复制。同时,子进程还获得了父进程的所有文件描述符,因此子进程可以读写父进程打开的任何文件,包括标准输入、标准输出。子进程和父进程拥有独立的虚拟地址空间、PID、时间片,拥有共同的进程组,它们在操作系统层面上是两个独立的进程。下图为这个过程的拓扑图:

6.4 Hello的execve过程
调用的fork函数会在父进程、子进程中各返回一次,在父进程的返回值为子进程的PID,在子进程中的返回值为0。进程发现自己是子进程时,则会调用execve函数加载将要执行的可执行目标文件。execve在当前进程的上下文加载并运行hello程序,具体操作如下:

  1. 删除子进程现有的虚拟内存段。
  2. 创建新的代码段、数据段、堆、用户栈的虚拟内存段和虚拟内存页。
  3. 将hello的数据段、代码段映射到合适的位置。
  4. 将RIP设置到ld-linux.so动态链接器的链接函数。
    动态链接器进行如下操作:
  5. 从.interp段中读取需要加载的动态链接库。
  6. 将所需目标文件以共享方式映射到虚拟内存中的共享库区域。
  7. 调用函数__libc_start_main,后者最终将调用main()函数
    6.5 Hello的进程执行
    进程上下文:每个进程有一个当前的执行环境,叫做上下文。进程的执行被打断后,必须将上下文恢复到打断之前的内容,进程才能继续执行。进程的上下文包括通用目的寄存器、浮点寄存器、程序计数器(RIP)、状态寄存器(RFLAGS)、进程的全局页目录指针(GPD,一级页表指针,存放在CR3寄存器内)、内核栈、私有的内核数据结构(如mm_struct,task_struct)。
    进程时间片:一个CPU上运行多个进程,是通过进程间的快速切换实现的,称作进程调度。每个进程都被分配了一小段时间,在这段时间内占用CPU,如果时间用尽时进程还在运行,内核会抢占这个进程,保存它的上下文,切换到另一个进程执行。
    用户模式与内核模式的切换:进程调度可以被系统调用、IO操作、计时器中断触发。异常处理程序常在内核模式下执行,使用内核栈。从进程A切换到进程B时,内核会代表进程A在用户模式下执行一段时间,然后切换到内核模式执行一段时间,接着切换到进程B,并在内核模式下执行一段时间,最后在进程B下以用户模式执行。整个过程中,内核总是代表某个进程执行,而不存在独立的内核进程。下图为《深入理解计算机系统》一书中描述进程切换的插图。

6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
异常可分为四类:中断、陷阱、故障和终止。

  1. 缺页异常:CPU第一次访问堆栈、第一次访问代码时,对应的页还没有被加载到物理内存中。这时,MMU会触发缺页保护故障,调用操作系统的缺页处理子程序,后者最终将所需的页面读入物理内存,重启刚才的指令。
  2. 硬件计时器中断:这个计时器信号是周期性的,用来支持进程的时间片轮转,防止一个进程占用CPU过久。这个计时器信号触发的异常处理子程序会调用进程管理模块的进程切换操作,保存当前的上下文、恢复下一个进程的上下文,并将控制权交给下一个进程。
  3. I/O中断:当用户按下键盘的按键时,会触发一个中断,程序此时可以针对用户的输入做出处理。
  4. SIGTSTP:用户按下了Ctrl-Z组合键。此时进程会收到SIGTSTP信号,被暂停执行,内核不再为其分配时间片,处于stopped状态。
  5. SIGINT:用户按下了Ctrl-C组合键。此时进程会收到SIGINT信号,进程收到信号后会退出。

6.7本章小结
本章介绍了hello被Shell加载的过程,以及Shell运行时通过信号与Shell、与用户、与内核通信的原理。用户通过特殊的组合键向前台进程hello发送信号,hello收到信号后改变自身的运行状态;父进程Shell检测到子进程hello进程变化后,做出相应操作,如输出提示信息。hello被挂起后,进程仍然存在,资源仍未被释放,只不过处于挂起状态,不再被分配CPU时间,因而表现为暂停执行。hello在终止执行后,需要由父进程Shell进行回收,在回收前,hello处于僵死状态(zombie),仍然占用系统资源。

第7章 hello的存储管理

7.1 hello的存储器地址空间
线性地址空间:线性地址空间是连续的地址空间,如hello的整个虚拟地址空间。
逻辑地址:相对于段寻址的地址,分为段基址和段偏移量两部分。
物理地址:计算机物理内存上存储单元的硬件地址,与虚拟地址相对,在实模式下访问内存使用的地址。hello中无法直接使用物理地址寻址,因为此时CPU处于保护模式中,hello只能访问自己进程的地址空间中的非内核页面。
虚拟地址:相对于每个进程私有的、独占的虚拟地址空间的地址,在保护模式下访问内存使用的地址。虚拟地址空间保存在磁盘中。连续的虚拟地址空间缓存在离散的物理内存页中,经由页表映射而来。hello程序中涉及到的所有内存地址均为虚拟地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理下,地址表示形式为:段地址:段偏移地址
段式寻址为历史遗留问题。8086 CPU使用这种方式来访问大于64K的内存。物理地址=段地址*16+段偏移地址。
在80286中,引进了保护模式的概念,段寄存器改名为段选择子。操作系统初始化GDT和LDT两个描述符表,应用程序在访问内存时,需要使用段选择子在GDT或LDT内查询段基址,再加上段偏移地址,即得到物理地址。保护模式下,应用程序使用的内存段相互隔离,因此安全性大大提高。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被视为一个数组,储存在磁盘上的一个文件内。虚拟内存中的每个字节都和虚拟地址空间内的一个虚拟地址一一对应,也就是数组的索引。文件内数组的内容被缓存在内存中,磁盘上的数据被分割成虚拟页,这些虚拟页和高速缓存中的块类似,是磁盘和内存之间的传输单元。虚拟页的大小和物理页的大小相同。如图。

7.4 TLB与四级页表支持下的VA到PA的变换
64位虚拟地址空间可以寻址的范围非常大,现在最新的64位Linux操作系统支持48位虚拟地址空间,可以寻址256TB内存空间。对应这么大的空间,需要一张很大的页表。事实上,现有的程序只能使用其中很小的一部分,所以页表的很多空间实际上被浪费掉了。我们只需要保存页表的很小一部分,这就是多级页表出现的原因。
在48位虚拟内存地址、四级页表的虚拟内存系统下,虚拟地址的高36位被分成4个9bit,当作四级页表的四个虚拟页号(VPN)。最低的12bit为最后一级虚拟页上的偏移(VPO)。这样,每张页表大小都为4KB,由于程序具有局部性,访问的页表条目(PTE)可以被缓存在TLB中,可以加速多级页表的查询,使得其并不比单级页表慢很多。如图。

7.5 三级Cache支持下的物理内存访问
较快的存储器往往更贵而且更小,为了平衡成本和速度,存储器被组织成存储器层次结构,经常用到的数据被存储在级别更高、速度更快的存储器中,相比之下使用频率更小的数据被存储在级别更低、容量更大的存储器中。
随着微电子技术的进步,CPU与内存的速度差距逐渐增大,高速缓存应运而生,被用来弥补这个差距。CPU在读写内存时,会试图先从高速缓存中读写数据。如果这个数据在高速缓存中,则直接返回;否则,就向下一级高速缓存中查询。现代Intel Core i7 CPU具有三级高速缓存,如果在这三级中都未命中,则只能从内存中读取相应数据,再从高速缓存中驱逐一个合适的行,将读取的高速缓存行写入缓存;如果是向内存写入数据的情况,上述流程类似,只不过在写回机制下,向内存的写入要被推迟到这一行被驱逐出高速缓存时才进行,这样可以减小数据总线中的数据流量。
由于高速缓存需要从硬件层面并行查找多组数据,相联度高的缓存制造起来很困难,所以常用的高速缓存为组相联高速缓存:物理地址被分割为标志块、组索引和块偏移三部分,组索引相同的地址被映射到同一个组中,标志块用于确认该高速缓存行是否匹配,块偏移用于确定所需的字节的具体位置。
7.6 hello进程fork时的内存映射
当fork函数创建Shell进程的一个副本时,它并不将Shell的虚拟页全部复制一份,相反,共享页被标记为共享的,在物理内存中只有一份页面;私有页被标记为写时复制的,也就是说,在子进程修改它的私有页前,两个进程共享同一个页面,直到修改时才创建独立的副本。这样更加节省物理内存。如下图。

7.7 hello进程execve时的内存映射
execve函数调用内核的启动加载器代码,在当前进程中清除已有的虚拟内存页、加载目标可执行文件的数据段、代码段到虚拟地址空间,用hello程序代替当前程序,并将RIP设置为动态链接器或者.init段中定义的入口函数。详细步骤如下:

  1. 删除已存在的用户区域、当前进程的用户地址空间中已存在的区域。
  2. 映射私有区域。将可执行文件中适当位置映射到只读代码段和读写段中的.data节,将.bss节映射到适当大小的匿名文件(即请求二进制零),将堆、用户栈映射到初始长度为零的匿名文件。
  3. 映射共享区域。动态链接器将共享对象加载到虚拟内存中的共享区域内。
  4. 设置RIP,将其指向到代码区域的入口函数。
    7.8 缺页故障与缺页中断处理
    缺页故障:当CPU试图访问一个没有缓存在物理内存中的页面时,MMU会触发一个保护故障,即缺页故障。缺页异常处理程序会从物理内存中选择一个适当的牺牲页,将这个页写回到磁盘,然后将需要的页面从磁盘中载入内存,CPU重启引起缺页的指令,MMU正常翻译并返回物理地址。
    7.9动态存储分配管理
    动态内存分配器从堆中分配内存块,提供给应用程序使用。堆是虚拟内存中的一个区域,分配器使用某种策略在堆上分配内存块。块在使用完毕后必须释放的内存分配器,称作显示分配器;会自动检测块是否不再使用的分配器,称作隐式分配器。隐式分配器自动释放不再可用的块的过程称作垃圾收集。
    下面介绍带边界标签的隐式空闲链表分配器:

块的头部和尾部完全相同,尾部的作用是从它下面的块定位这个块。堆常见的管理方式有隐式空闲链表和显式空闲链表。由于块在堆中顺序分布,所以块可以组织为一个链表,其中夹杂着空闲块和已分配块;如果跳过已分配块,则可以以O(n)的复杂度遍历所有的空闲块,这种组织方式成为隐式空闲链表。
事实上,我们只需要知道空闲块相关的信息,并不关心一个已分配块的位置和大小。所以,我们可以在有效载荷部分重叠加入一个指针,这个指针将空闲块串联成一个新的链表,称作显式空闲链表。遍历显式空闲链表比遍历隐式空闲链表要快很多,因为已分配块不在链表中。如果将空闲块按照大小排序,并按大小分类,组织多个表头,则可以进一步减小搜索空闲块的时间。这种组织方式称作分离适配。
GCC的动态内存分配器使用分离适配模式。
7.10本章小结
本章介绍了hello的内存管理机制、段式内存管理、虚拟内存、多级页表、进程加载时fork和execve函数的处理流程、缺页处理机制和应用级内存块管理:动态内存分配器。可见,hello要想执行,还离不开完善的虚拟内存机制、进程管理机制和动态内存分配机制。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法
I/O设备可以被抽象为文件,也就是一个字符序列,可以连续地输入、输出字符串。应用程序与设备的通信被内核抽象为Unix I/O函数,借助这组函数,内核通过驱动程序与设备通信,应用程序间接地操作设备。
8.2 简述Unix IO接口及其函数

  1. int open(char *filename, int flags, mode_t mode);
    功能:打开指定的文件,存储在全局的文件表中,并返回一个文件描述符。返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件, mode参数指定了新文件的访问权限位。
  2. int close(int fd);
    关闭一个打开的文件。
  3. ssize_t read(int fd, void *buf, size_t n);read函数从描述符为 fd的当前文件位置赋值最多 n个字节到内存位置buf。返回值 -1表示一个错误, 0表示 EOF,否则返回值表示的是实际传送的字节数量 。
  4. ssize_t write(int fd, const void *buf,size_t);
    write函数从内存位置 buf复制至多 n个字节到描述符 fd的当前文件位置。
    8.3 printf的实现分析
    printf函数读取可变长参数表,调用vsprintf函数将字符串按照指定的模式格式化,并写入内存中的缓冲区。返回值为要输出字符串的函数。
    要将最终的字符串写入标准输出文件,需要使用write系统调用。write的第一个参数为文件描述符,1为每个进程的标准输出文件,所以这里传递1;第二个参数为缓冲区的虚拟地址,第三个参数为缓冲区的长度。调用write系统调用后,hello程序陷入内核,内核将缓冲区的数据写入文字缓冲区(如果显示器工作在文字模式下),显示卡对每一个字符查找ASCII字型库,在视频内存(VRAM)中写入对应字符的形状,显示芯片按照刷新率读取视频内存(VRAM),以合适的编码通过视频线发送到显示器,后者在屏幕上显示最终的符号。

8.4 getchar的实现分析
当用户按下某个按键时,键盘里的芯片会将键编码为一个键盘扫描码,计算机IO接口接收到扫描码后会产生一个中断请求,调用键盘中断处理子程序,键盘中断子程序从键盘IO取得该按键的扫描码,然后将该按键扫描码转换成 ASCII码,保存到系统的键盘缓冲区中。键盘缓冲区被组织为标准输入文件,应用程序调用标准I/O函数,从这个文件中读取字符,即可获得键盘输入的字符。getchar函数的实现如下:

int getchar(void)  
{ 
	static char buf[BUFSIZ];  
    static char* bb=buf;  
    static int n=0;  
    if(n==0) {  
		n=read(0,buf,BUFSIZ);  
		bb=buf;  
    }  
    return(--n>=0)?(unsigned char)*bb++:EOF;  
}  

可见,getchar函数循环地从标准输入文件(文件描述符为0)中读取字符,直到读取到一个有效字符,将字符返回给用户。
8.5本章小结
本章介绍了Linux下I/O设备的抽象形式:文件。Unix还提供了配套的文件I/O函数,可以用统一的方法,读写各种各样的设备,简化了应用程序的开发设计,并且更加安全。

结论

hello的一生,短暂而又精彩:

  1. hello.c经过预处理器,得到预处理后的文件hello.i
  2. 编译器将hello.i编译为更接近机器语言的汇编代码hello.s
  3. 汇编器将hello.s翻译为机器指令,得到可重定位目标文件hello.o
  4. 链接器将hello.o与其他库函数的可重定位目标文件、存档文件进行链接,组合、重定位为一个可以直接加载到内存映像中的可执行目标文件hello
  5. 用户在Shell中键入命令,执行hello
  6. hello.o由Shell的帮助,在fork产生的Shell子进程中由execve映射相应段到虚拟内存
  7. execve加载动态链接器,后者将动态链接库映射到虚拟内存,并进行一些动态链接工作、调用hello的入口函数
  8. 第一次访问hello的代码段时,MMU产生一个缺页保护故障,调用缺页故障处理子程序,将代码页载入物理内存
  9. hello访问内存中的数据,CPU向L1 Cache发送请求,L1 Cache中发生冷不命中,请求经由L1 Cache、L2 Cache、L3 Cache层层传递,最终到达物理内存,从内存中读取一个缓存行,刷新各级缓存,返回读取的数据……
  10. hello的入口函数最终调用main函数,我们的代码终于可以在机器上执行了
  11. hello的运行状态改变时,内核会以信号的形式通知它的父进程:Shell。Shell接收信号,得知hello的运行状态已改变,从而做出相应处理。
    计算机是一个设计精密而复杂的电子机器。从底层的微操作实现,到高层的大型应用程序的运行,需要许多复杂的层次结构相互协调运作,各个部件环环相扣,借助各种技巧,如高速缓存、流水线,突破工艺及技术所造成的物理限制,达到尽可能高的计算性能。同时,虚拟内存、基于编译器和解释器的高级程序设计语言、标准I/O设备接口等技术也极大地降低了开发难度,使得人们能够更方便地使用计算机,方便我们的生活。
    附件
    文件名称 作用
    hello.i hello.c 源文件预处理之后得到的修改过后的文本文件
    hello.s hello.i 经过编译之后得到的汇编程序
    hello.o hello.s 经过汇编之后得到的可重定位的二进制文件
    hello hello.o 经过链接得到的可执行文件
    hello.elf hello.o 的 elf 文件
    hello.asm hello.o 的反汇编代码文件
    hello2.elf hello 的 elf 文件
    hello2.asm hello 的反汇编代码文件

参考文献

[1] 深入理解计算机系统 [M] Randal E. Bryant, David R. O’Hallaron
[2] Linux Standard Base Specification 1.2 Chapter 4. Special Sections https://refspecs.linuxfoundation.org/LSB_1.2.0/gLSB/specialsections.html
[3] What is the difference between .got and .got.plt section in ELF format? https://stackoverflow.com/questions/11676472/what-is-the-difference-between-got-and-got-plt-section.
[4] https://zh.cppreference.com/w/c
[5] https://gcc.gnu.org/onlinedocs/

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值