计算机系统
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
计算机科学与技术学院
2022年5月
本文通过详细分析hello从预处理、编译、汇编、链接、在进程中运行的全过程。细致的讲述了该过程中涉及到的各个阶段的知识点,并且将CSAPP中前8章的内容概述的梳理融入到讲解过程中,有益于对计算机系统的理解。
关键词:计算机系统 预处理 编译 汇编 链接 进程
目 录
第1章 概述
1.1 Hello简介
1、P2P(From Program to Process):
图1 hello编译的过程
Hello从源文件转化为目标文件是由编译器驱动程序完成的,在此过程中它历经艰难险阻,经历了预处理、编译、汇编和链接才最终转化为可执行目标程序,并保存在磁盘中。之后在运行阶段,在壳shell中输入命令(./hello)后,操作系统(OS)的进程为其调用fork,创建一个新的子进程,这样hello就是实现了从程序到进程的转变,该过程就是P2P。
- 预处理:在预处理阶段,预处理器(cpp)根据以字符‘#’开头的命令,修改源程序。将对应系统头文件的内容直接插入到程序文本中,得到的c文件即为hello.i。
- 编译:在编译阶段,编译器(cc1)将文本文件(hello.i)转化为文本文件(hello.s)。该文件(hello.s)是用汇编语言描述的低级机器语言指令。
- 汇编:在汇编阶段,汇编器(as)将hello.s中汇编语言描述的机器指令一一对应的翻译为二进制描述的机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。
- 链接:在链接阶段,链接器(ld)将hello调用的标准c库中的printf函数与helllo.o文件进行合并,从而得到了可执行目标文件(hello),该文件可以被加载到内存中,由系统执行。
2、020(From Zero-0 to Zero-0)
Hello变为进程后,shell将调用操作系统内核提供的execve()函数,该函数将创建出虚拟内存的映射,并开始加载物理内存。随后CPU为hello分配逻辑控制流。在进程终止后,shell将回收进程同时操作系统释放虚拟空间。Hello从不存在(0)到被创建、运行、最后被回收(0)的过程,就是020。
1.2 环境与工具
1、硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
2、软件环境:Windows10 64位 Ubuntu 16.04
3、开发工具:Visual Studio 2020;CodeBlocks 64位;vi/vim/gedit+gcc;edb;
gcc;cc1;ld;as;readelf;Objump;gdb
1.3 中间结果
名称 | 作用 |
hello.c | 源程序文件 |
hello.i | 预处理后的文本文件 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 链接后的可执行文件 |
1.4 本章小结
本章主要介绍了hello.c一生中P2P与O2O的概念,并对对实验环境,中间结果做出了简要的分析与罗列。
第2章 预处理
2.1 预处理的概念与作用
1、预处理的概念
预处理简单来说,就是在编译之前源文件进行简单加工的过程。更细致的来说,预处理指的是程序在编译之前进行的处理,是计算机在处理一个程序时所进行的第一步处理。
2、预处理命令的分类及作用
(1)宏定义处理
- 不带参数的宏定义:在宏定义区间进行简单的字符串替换
例如:# define 标识符 字符串
- 带参数的宏定义:除了简单的字符替换,还要进行参数替换
例如:#define 宏名(参数表) 字符串
(2)文件包含处理
将一下通用的配置或者预处理定义放到一个文件中,每个需要使用该配置的文件只要包含一下该文件就可以减少重复的工作。
例如:# include “文件名”或《文件名》
(3)条件编译处理
为了令程序中的一部分内容在满足一定条件下才进行编译,也就是这部分内容指定了编译条件,这种情况就是条件编译处理。条件编译处理提高了C语言的通用性,不同的计算机能兼容的执行C语言的代码程序。
例如:# ifdef 标识符 程序段1 # else 程序段2 # endif
图2 常见的预处理指令
3、预处理的作用
预处理器(cpp)可以识别以‘#’开头的预处理命令,根据不同的预处理命令,分别修改对应的原始c程序。
- 对于宏定义,在不带参数的宏定义中,用实际值代替定义的字符和字符 串;在带参数的定义中,同时替换实际值,替换参数。在宏替换中,仅仅是替换,不对表达式进行求值和计算。
- 对于文件包含:#include指令能够告诉预处理器读取源程序中所引用的系统的源文件,并且将这一段代码直接插入到程序文件中,最终保存到.i文件中。
例如:将 # include <stdio.h> 命令告诉预处理器读取系统头文件stdio.h的内容,并将它直接插入到程序文本中,从而得到.i文件。
(3)对于条件编译:条件编译能够根据不同条件决定需要进行编译的代码,使用条件编译可以使目标程序变小,在满足条件之后才会进行编译。
2.2在Ubuntu下预处理的命令
- 在Ubuntu下的预处理指令
cpp hello.c hello.i
2、结果展示:
图3 Ubuntu下hello.c的预处理
图4 hello.i和hello.c的字节比较
3、分析:从图3、图4中红框所圈的内容可以对比得出,hello.c占77个字节,而hello.i占16332字节。显然,hello.i中插入了大量的内容。
2.3 Hello的预处理结果解析
图5-1 hello.i文本内容
图5-2 hello.i文本内容
图5-3 hello.i文本内容
2、从图5-2中,我们可以看出cpp将读取了stdio.h、unistd.h、stdlib.h中的内容,并将其直接插入了源文件中。
3、从图5-3中可以看出,还引入了外部变量等信息。
综上,我们可以看出,在hello.c转变为hello.i的过程中发生了宏展开,并且从图6可以看出hello.i不包含任何的define!
图6 hello.i中查找define结果
2.4 本章小结
本章主要介绍了预处理的概念,一些基本的预处理命令及与预处理的作用。同时,通过通过在Ubuntu上的运行和结果分析,可以得出预处理进行了宏定义替换的操作。
第3章 编译
3.1 编译的概念与作用
1、编译的概念
此处的编译是指编译器(cc1)将预处理得到的.i文件编译为.s文件的过程。其中.s文件是一种用文本描述机器指令的集合。
注意:这里的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序。
2、编译的作用:
编译阶段首先进行词法分析和语法分析,检查代码的规范性、是否有语法错误等。接下来对代码进行优化,最后生成汇编代码。将高级语言转变为了含有汇编语言的文本文件,简化了将高级语言转化为计算机可执行的二进制文件时的操作。
3.2 在Ubuntu下编译的命令
1、常见的编译指令:
(1)gcc -S hello.c -o hello.s
(2)cc1 main.i -Og -o -main.s
2、过程:
图7 hello.i->hello.s的过程
由于我的Ubuntu中cc1不在路径下,所以采用了绝对寻址的方式(图7)。
- 结果:成功生成了hello.s文件(图8)
图8 编译结果
3.3 Hello的编译结果解析
3.3.1 数据
1、常量
图9 hello.s .rodata节
如图9所示,,file表示源文件为hello.i;.text下面是代码段;.section .rodata节;.align声明对指令或数据的存放地址进行对齐的方式为8。从.LC0和.LC1处可以看出printf中的两个字符串常量被保存在.rodata节。
2、局部变量
局部变量是在调用过程中在栈中声明的,所以在.s文件(图10)我们可以看到i被存储在距离栈帧4字节的部分,并跳转到.L3开始的地方进行循环。
图10 局部变量
- 立即数
立即数在编译过程中是直接写在代码里的,如图11所示,4和8都是直接出现在汇编语言中的。
图11 立即数
- 数组
在hello中,整型argc和字符串数组argv作为主函数的参数,在调用main函数时,分别以寄存器rdi和rsi传递数据。如图12所示,argv 指针指向已经分配好的、一片存放着字符指针的连续空间,起始地址为 argv,main 函数中访问数组元素argv[1],argv[2]时,通过相对于rbp的偏移量,按照起始地址 argv 大小 8B 可以计算数据地址取数据,在hello.s 中,引用两次(%rax)(两次 rax 为argv[1]和 argv[2]的地址)。
图12 数组
3.3.2赋值操作
hello.c中的赋值操作是对for循环中的i的操作。其实实现的主要方法是利用mov传送传送指令。mov指令的后缀含义为:b(传送一字节),w(传送两字节),l(传送四个字节),q(传送八个字节)。传送指令的源可以是立即数、内存中的值、寄存器,目的可以是内存或者寄存器。在hello中i的赋值主要用到的指令为movl(图13)。
图13 i的赋值
3.3.3算术操作
在汇编语言中,加法的实现利用的是add指令;减法的实现利用的是sub指令。 常见的算数指令如图14-2所示。其中add指令和sub指令后缀修饰符同mov类指令。hello.c文件中的i++;在汇编代码中使用addl指令实现(图14-2),将1加到i上。
图14-1 常见算数指令
图14-2 i++操作
3.3.4比较与跳转
在汇编语言中,比较是利用cmp指令,通过两操作数值只差来设置条件码(CF:进位标志;ZF:零标志;SF:符号标志;OF:溢出标志),该指令后面可以跟跳转指令。如图15所示,是一些常见的跳转指令。
图15 跳转指令
在hello中,利用的是cmpl指令进行的,同时后跟跳转指令(图16)
图16 循环条件的比较
3.3.5 条件控制
hello.c中存在一条条件比较语句,如果argv不等于4,则直接退出。这个操作也是通过cmp语句和跳转指令实现的(如图17)。
图17 条件比较
3.3.6 循环结构
hello中有一处循环结构,用局部变量i<9作为循环控制条件,i存放在-4(%rbp),首先先将i置为0,然后跳转到.L3,如果小于等于8,则跳转到.L4,进入循环体,首先进行取数操作,由于argv是指针数组,所以要进行二次寻址,前三行取argv[2],接着3行取argv[1],分别存放在rdx和rsi中,然后调用print函数打印相关信息,若不满足条件,则调用getchar函数。其具体实现如图18所示。
图18 循环条件
3.3.7 函数操作
1、函数调用:通过call+函数名实现,call指令会把调用该指令的下一个地址压入栈中,并将PC设置为Q的起始地址,压入栈中的地址即为返回地址。hello中的函数调用如图19所示。
图19 函数调用
- 函数返回:通过ret指令返回,首先将栈帧+8,从栈中弹出返回地址,将PC设为该值,从而达到函数返回的目的。hello中的返回指令,如图20所示。
图20 函数返回
- 参数传递:rdi、rsi、rdx、rcx、r8、r9,多余参数通过栈来传递;返回值:rax保存,如图21所示。
图21 参数传递
3.4 本章小结
本章通过命令行命令将hello.i在Ubuntu下转变为hello.s,结合编译的概念和作用,分析hello.s中出现的数据(全局变量、局部变量、数组、立即数)、赋值操作、算术操作、比较与跳转、条件控制、循环控制、函数操作(函数调用、函数返回、参数传递)进行了分析,并解释了在汇编语言下一般出现的形式,有利于对于编译机制的理解。
第4章 汇编
4.1 汇编的概念与作用
1、概念:
汇编是指汇编器(as)将hello.s翻译为机器语言,并产生可重定位目标文件(hello.o)的过程。hello.o是一个二进制文件,它将hello.s中的用文本表述的机器指令大体上一对一地翻译为由0、1组成的机器指令。
- 作用:
将hello.s翻译成了机器语言,并把这些指令打包成了一种叫做可重定位目标程序的格式。
注意:
(1)这些指令是可以直接在机器上运行的,但由于hello.o还未经历后面将要叙述的链接过程,所以hello.o无法直接运行。
(2)此处的汇编是指从 .s文件 到 .o文件。即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
1、汇编命令行
(1)gcc -c –m64 –no-pie –fno-PIC hello.s -o hello.o
(2)as -o main.o main.s
2、汇编过程与结果
图22 汇编过程
4.3 可重定位目标elf格式
4.3.1 ELF可重定位目标文件的格式
图23 典型的可重定位ELF格式
4.3.2 ELF中节的解析
1、ELF头
ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。剩下部分如图23所示,包含了帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小,目标文件的类型(如可重定位、可执行或者共享的),机器类型(如 X86-64 AMD),节头部表的文件偏移,以及节头部表中条目的大小和数量。不同的节位置和大小都是由节头部表描述的。
2、.text
已编译的机器代码
- rodata
只读数据,比如printf语句中的格式串和开关语句的跳转表,立即数(如:Π)。
- .data
已初始化的全局和静态C变量。
注意:局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。
- .bss
未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际空间,它仅仅是一个占位符。运行时,在内存中分配这些变量,初始值为0。
- .symtab
一个符号表,存放在程序中定义和引用的函数和全局变量信息。
注意:与编译器中的符号表不同,该符号表不含局部变量的条目。
- .rel.text(可重定位代码)
一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般来说。任何调用外部函数或者引用全局变量的指令都需要修改,调用本地函数的指令则不需要修改。
- .rel.data
被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
- .debug
一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量以及原始的C源文件
- .line
原始C源程序中的行号和.text节中机器指令之间的映射。
- .strtab
一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的接名字。字符串表就是以null结尾的字符串的序列。
4.3.3 hello中各个节的信息
1、ELF头
图24 hello.o的ELF头
如图24所示,文件类型为可重定位文件,包含了13个节。
- hello.o的各个节
图25 hello.o的节信息
如图25所示,我们可以查看到各个节的地址和偏移量。
- hello.o的符号表
图26 hello.o的符号表
从图26中我们可以看到程序中涉及的符号名称以及所占大小、类型、存在在哪个节中。
- hello.o的可重定位节
图27所示的为我们可以得到的重定位条目中的信息,包括:offset(需要被修改的引用节的偏移)、symbol(标识被修改引用应该指向的符号)、type(链接器如何修改新的引用)以及addend(偏移调整)。
图27 重定位条目
- 类型有两种:绝对地址引用和PC相对寻址
- R_X86_64_32:绝对地址引用,意思是重定位时使用一个32位的绝对地址的引用,通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
- R_X86_64_PC32:PC相对寻址,意思是指在重定位时使用一个32位PC相对地址的引用。
2、图28-1、28-2所示的即为hello.o中的重定位节的信息。
图28-1 hello.o的可重定位节
图28-2 hello.o的可重定位节
4.4 Hello.o的结果解析
反汇编hello.o的结果:
图29 hello.o的反汇编结果
4.4.1 机器代码
1、机器语言的构成
(1)操作码。它具体说明了操作的性质及功能。一台计算机可能有几十条至几百条指令,每一条指令都有一个相应的操作码,计算机通过识别该操作码来完成不同的操作。
(2)操作数的地址。CPU通过该地址就可以取得所需的操作数。
(3)操作结果的存储地址。把对操作数的处理所产生的结果保存在该地址中,以便再次使用。
(4)下条指令的地址。执行程序时,大多数指令按顺序依次从主存中取出执行,只有在遇到转移指令时,程序的执行顺序才会改变。为了压缩指令的长度,可以用一个程序计数器存放指令地址。每执行一条指令,PC的指令地址就自动加一,指出将要执行的下一条指令的地址。当遇到执行转移指令时,则用转移地址修改PC的内容。由于使用了PC,指令中就不必明显地给出下一条将要执行指令的地址。
2、如图29所示,hello.o反汇编得到的结果左侧多了一些操作数,这些就是该语句的机器码,长度1~15字节不等。
3、机器语言与汇编语言大体上都是一一对应的,但是机器语言对机器硬件的依赖性强,可以移植性较差。
4.4.2 hello.o的反汇编与hello.s的对比
反汇编与.s文件中的主体大致相同,但在以下部分有一些细微差距。
(1)函数调用的不同:hello.s文件中对于函数的调用是通过call+函数名进行的,反汇编中是通过call+函数的起始地址进行的。同时反汇编文件中还给出了重定位条目。
图30 函数调用的对比
(2)跳转指令的不同:.s文件中是通过每一段的名字跳转的(eg:.L0),反汇编是通过所在节起始+地址偏移量进行的。同时反汇编文件中还给出了重定位条目。
同时反汇编文件中还给出了重定位条目。
图31 跳转指令的对比
(3)对栈的利用不同:.s文件由于要采用对齐,对栈的利用较低,而反汇编对栈的利用率较高。
(4)操作数:.s文件中立即数等操作数采用的是10进制,而反汇编中采用的是16进制。
图32 操作数的对比
4.5 本章小结
本章通过汇编器(as)将hello.s转变为了可重定位目标文件hello.o,同时通过readelf命令查看了可重定位目标文件的相关信息。
同时,通过objump命令对hello.o文件进行了反汇编,将所得的结果与hello.s文件进行了对比,分析了差异以及机器语言与汇编语言的映射关系。
第5章 链接
5.1 链接的概念与作用
1、概念
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。
链接可以执行与编译时,也就是在源代码被翻译为机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;也可以执行于运行时,也就是由应用程序来执行。
在现代系统中,链接是由链接器程序自动执行的。
- 作用
可以将一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。
链接分为两个过程:
- 符号解析:将每个符号引用于符号定义关联起来。
- 重定位:编译器(cc1)和汇编器(as)生成从地址0开始的代码和数据节。连接器通过把每个符号定义与一个内存位置管理关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
链接可以将一组常用的文件模块组成的静态库和动态库链接到可重定位目标文件上,以减少内存的占用,同时也可以使程序员不必因为库的更新重写编写编译文件。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
1、链接命令行
由于hello中涉及到了动态链接库lib.so中的文件,所以链接的命令如下:
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
2、链接过程及结果
图33 hello的链接过程
5.3 可执行目标文件hello的格式
5.3.1 可执行目标文件的ELF信息
图34 典型的ELF可执行目标文件
- 可执行目标文件的格式类似与可重定位目标文件的格式。ELF头描述文件的总体格式,还包括了程序的入口点(即当程序运行时要执行的第一条指令的地址)
- .text、.rodata、.data节与可重定位目标文件中的节类似,区别是:可执行目标文件中的节已经被重定位到它们最终的运行时内存地址。
- .init节中定义了一个函数_init,在程序初始化时会调用它。
5.3.2 hello中ELF的相关信息
1、ELF头(图35)
图35 hello的ELF头
文件类型为可执行文件,包含了27个节。
2、节(图36)
图36 hello中的节
从图中可以看出在经过动态链接后增加了许多节。
(1).dynsym:动态链接符号表,.dynsym节保存在text段中。其保存了从共享库导入的动态符号表。节类型为SHT_DYNSYM。
(2).dynstr:动态链接字符串表,保存了动态链接字符串表,表中存放了一系列字符串,这些字符串代表了符号名称,以空字符作为终止符。
(3).got:.got节保存了全局偏移表。
(4).gotplt:全局偏移表-过程链接表
(5).hash:.hash节也称为.gnu.hash,其保存了一个用于查找符号的散列表。
3、符号表(图37)
图37 hello的符号表
4、程序头(图38)
图38 hello.o的程序头
5、段节(图39)
图39 hello的段节
- 动态节的入口(图40)
图40 hello的dynamic entries
- 重定位节(图41)
图41 hello的重定位节
图35~41显示了hello中ELF的各类信息,在其中我们可以看到各段、各节的起始地址、大小等基本信息。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图42 hello的Data Dump
从图42中可以看出程序在地址0x00401000~0x00402000段中,程序是从0x00400000开始的。
对于各节在虚拟地址的位置可以通过计算偏移量来得到。例如:.text节的位置在0x0040010f0处,从图43可以看出确实在地址0x0040010f0处。
图43 text的虚拟空间位置
.fini的偏移量为0x00001238,故地置地址为0x00401238,如图44所示。
图44 .fini的虚拟空间位置
5.5 链接的重定位过程分析
5.5.1 hello与hello.o的不同
1、如图45所示,hello多出init和plt这两节。
(1)init:包含程序初始化时需要的代码
(2)plt:.plt节也称为过程链接表(Procedure Linkage Table),其包含了动态链接器调用从共享库导入的函数所必需的相关代码。由于.plt节保存了代码,所以节类型为SHT_PROGBITS。
图45 init节与plt节
2、函数增加
在链接过程中,会将所用到的动态链接库中定义的函数加载到程序中。如图46所示,hello中增加了put、printf、sleep、getchar、exit、atoi、_start和_dl_relocate_static_pie的定义内容。
图46 hello中增加的函数
3、寻址方式
如图64所示,在hello中,.o文件中的重定位条目全部消失,全部更改为对应的虚拟内存中的地址。
图47 hello的反汇编
5.5.2链接的过程
- 符号解析
链接器将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号关联起来。
2、重定位
在完成符号解析后,链接器就知道了输入目标模块中的代码节和数据节的确切大小。然后就可以进行重定位,重定位两步:
- 重定位节和符号定义:在该步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。之后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节以及赋给输入模块定义的每个符号
- 重定位节中的符号引用:在该步中,链接器修改代码节和数据节中对每个符号的引用,使它们指向正确的运行地址。
5.5.3 hello的重定位
重定位时,hello主要依靠重定位条目来进行修改。ELF定义了32种不同的重定位类型,其中最主要的两种为R_x86_64_PC32和R_X86_64_32。
(1)R_x86_64_PC32:重定位一个使用32位PC相对地址的引用。一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址(如call指令的目标),PC值通常是下一条指令在内存中的地址。
(2)R_X86_64_32:重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
例如:在重定位中__gmon_start__为绝对寻址,偏移量为0x00403ff8,加数为0(图48-1),在符号表中起始地址为0(图48-2),故运行时的地址应该为0x403ff8(图48-3)。
图48-1
图48-2
图48-3
其余的重定位条目类似可以推断得出。
5.6 hello的执行流程
名称 | 地址 |
/usr/lib/x86_64-linux-gnu/ld-2.31.so | 00007fea7ba14100 |
libc-2.31.so!_libc_start_main | 00007fbdfa240f90 |
libc-2.31.so!_cxa_atexit | 00007fbdfa263de0 |
/usr/lib/x86_64-linux-gnu/libc-2.31.so | 00007fbdfa263b80 |
libc-2.31.so!_setjmp | 00007f63c2739c80 |
_init | 0000000000401000 |
puts@plt | 0000000000401090 |
_start | 00000000004010f0 |
main | 0000000000401125 |
_fini | 0000000000401238 |
5.7 Hello的动态链接分析
5.7.1 共享库
共享库(shared library)是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)的程序来执行的。共享库也称为共享目标(shared object),在Linux系统中通常用.so后缀来表示。微软的操作系统大量地使用了共享库,它们称为DLL(动态链接库)。
动态链接器通过执行下面的重定位完成链接任务∶
(1)重定位libc.so的文本和数据到某个内存段。
(2)重定位libvector.so的文本和数据到另一个内存段。
(3)重定位prog21中所有对由libc.so和libvector.so定义的符号的引用。
最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就 固定了,并且在程序执行的过程中都不会改变。
5.7.2 hello的动态链接
现代编译器使用PIC(位置无关代码:可以加载而无需重定位的代码)来避免加载时占用过多内存,同时可以是多个可执行文件共享一个文件。
1、PIC数据引用
图49 GOT引用全局变量
2、PIC函数调用
图50 用PLT和GOT调用外部函数
2、hello的调用
由于PIC函数的存在,编译器无法预测运行地址,所以采用延迟绑定的方法。动态链接器使用PLT+GOT结合的方式实现函数的动态链接。其中,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
(1)在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。
(2)在dl_init调用之后,其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址, GOT[2]指向动态链接器ld-linux.so运行时地址。
图 51 动态链接前后的变化
(3)在之后的函数调用时,控制传递到PLT[2]后将通过GOT的简介跳转直接将控制转移到main函数。
5.8 本章小结
本章通过链接器(ld)将hello.o文件和动态库链接得到了一个可执行文件。同时结合链接的概念、作用、步骤、以及对hello进行反汇编、edb调试,简单说明了hello在链接时发生的一系列变化。
第6章 hello进程管理
6.1 进程的概念与作用
1、进程的概念
狭义上来说:进程是一个执行程序的实例。广义上来说:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。狭义上来说:进程是一个执行程序的实例。系统中的每个程序都运行在某个进程的上下文中,通过进程,会给我们提供两种假象:我们的程序是系统当前运行的唯一程序,独立地使用处理器和内存;程序中的代码和数据是系统内存中唯一的对象。
2、进程的作用
提供给操作系统(OS)上的程序两个抽象:一个独立的逻辑控制流和一个私有的虚拟地址空间。进程的抽象,有利于多任务同时进行,实现流水线,提高效率。
6.2 简述壳Shell-bash的作用与处理流程
1、作用
(1)shell是一个交互型的应用级程序,它代表用户运行其他程序。shell是一种程序设计语言,作为命令语言,它交互式解释和执行用户输入的命令或自动地解释和执行预先设计好的一连串的命令;作为程序设计语言,它定义了各种变量和参数,并提供了许多在高级语言中才具有的控制结构,包括循环和分支。
(2)shell作为一个应用级程序,提供了一个界面,使用户可以通过访问这个界面来访问操作系统内核的服务。
2、处理流程
(1)从终端读取用户输入的命令
(2)调用parseline函数,解析以空格分隔的命令行。若为shell的内置命令,解释执行,不是内置命令,调用相应的程序执行。
(3)根据是否存在&,来确定在程序的前台还是后台运行。
(4)shell同时也会接受来自键盘的输入信号,并对这些信号进行相应的求值。
6.3 Hello的fork进程创建过程
2、子进程得到与父进程用户级虚拟地址空间相同但是独立的一份副本,包 括代码、数据段、堆、用户栈、共享库,打开的文本描述符。
3、父进程与子进程最大的区别是PID不同
4、fork函数,调用一次,返回两次。在父进程中,返回子进程的PID;在子进程中,返回0。
5、父进程与子进程是并发执行的,无法预测逻辑控制流中指令交替的次序。
6.4 Hello的execve过程
1、execve函数原型
图52 execve函数原型
2、加载过程
execve 函数加载并运行可执行目标文件filename,且带参数列表 argy 和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。
所以,与fork一次调用返回两次不同,execve调用一次并从不返回。参数列表是用图53中的数据结构表示的。argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0]是可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的,如图53所示。envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name=value”的名字-值对。
在execve加载了filename之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型:
int main(int argc,char **argv,char **envp)或int main(int argc,char *argv[],char *envp[]);
图53 环境变量列表的组织结构
3、执行过程中
当 main开始执行时,用户栈的组织结构如图54所示。让我们从栈底(高地址)往栈顶(低地址)依次看一看。首先是参数和环境字符串。栈往上紧随其后的是以null结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串。全局变量environ指向这些指针中的第一个envp[0]。紧随环境变量数组之后的是以null结尾的argv[ ]数组,其中每个元素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数libc_start_main的栈帧。
图54 新程序开始时,用户栈的典型情况
6.5 Hello的进程执行
1、用户态和核心态
(1)用户态:当进程在执行用户自己的代码时,则称其处于用户运行态
(2)核心态:当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态,此时处理器处于特权级最高的内核代码中执行。
2、时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
3、上下文
操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。在这个机制中,内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
4、调度
调度在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程
上下文切换包括三个部分:
(1)保存当前进程的上下文
(2)恢复某个先前被抢占的进程被保存的上下文
(3)将控制传递给这个新恢复的进程。
5、进程执行的过程
图55展示了一对进程A和B之间上下文切换的示例。在这个例子中,进程A初始运行在用户模式中,直到它通过执行系统调用 read 陷入到内核。内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。
图55 进程的上下文切换剖析
磁盘取数据要用一段相对较长的时间(数量级为几十毫秒),所以内核执行从进程A到进程B的上下文切换,而不是在这个间歇时间内等待,什么都不做。注意在切换之前,内核正代表进程A在用户模式下执行指令(即没有单独的内核进程)。在切换的第一部分中,内核代表进程A在内核模式下执行指令。然后在某一时刻,它开始代表进程B(仍然是内核模式下)执行指令。在切换之后,内核代表进程B在用户模式下执行指令。
随后,进程B在用户模式下运行一会儿,直到磁盘发出一个中断信号,表示数据已经从磁盘传送到了内存。内核判定进程B已经运行了足够长的时间,就执行一个从进程B到进程A的上下文切换,将控制返回给进程A中紧随在系统调用read之后的那条指令。进程A继续运行,直到下一次异常发生,依此类推。
6.6 hello的异常与信号处理
6.6.1异常的种类及分类(图56)
图56 四种异常
6.6.2 hello执行过程中出现的异常
1、正常运行
图57 正常运行hello
2、ctrl+c
在程序运行中,ctrl+c会使内核发送SIGINT信号给到前台进程组中的每个进程,从而使前台所有的进程终止
图58 hello运行中输入ctrl+c
3、ctrl+z
在程序运行中,ctrl+c会使内核发送SIGTSTP信号给到前台进程组中的每个进程,从而使前台所有的进程停止。
图59 hello运行中输入ctrl+z
- fg命令
fg的命令是是第一个后台进程变为前台重新运行,在上述过程中,第一个后台作业为hello。
图60 ctrl+z后输入fg
- 回车+乱按
在随意输入的过程中,如果识别到了回车,程序中的getchar函数会将回车读入,并将之前的字符当作shell的命令进行相应的操作。
图61 hello运行中随意输入
- jobs查看作业信息
图62 jobs命令
- pstree查看进程树
图63 pstree命令
- Kill
图64 kill命令
6.7本章小结
本章中简单说明了进程、shell的概念与作用。介绍了fork和execve函数创建、加载、运行程序的过程,并简述了进程调用的流程。
除此之外,测试了hello程序的执行,并对各种异常情况进行了分析,并做出了相应的程序处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1、逻辑地址:逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元、存储单元、网络主机的地址。
2、线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
3、虚拟地址:如果CPU启动了分页机制,经变化即可转变为虚拟地址;若未启动分页,线性地址即为虚拟地址。
4、物理地址:是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
- TI代表段选择符。TI=0,从全局段表中选择;TI=1,从局部段表中选择。
- 选择相应的段描述符表、找到堆顶的起始位置
- 起始位置加上偏移量即为线性地址。
图65 寻找线性地址示意图
7.3 Hello的线性地址到物理地址的变换-页式管理
从虚拟地址到物理地址的转变,计算机中是通过页表和MMU来实现的。如图66所示,CPU中的一个控制寄存器,页表基址寄存器(Page Table Base Register,PTBR)指向当前页表。n位的虚拟地址包含两个部分∶一个p位的虚拟页面偏移(Virtual Page Offset,VPO)和一个(n—p)位的虚拟页号(Virtual Page Number,VPN)。MMU利用VPN来选择适当的PTE。例如,VPN 0选择PTE 0,VPN1选择PTE1,以此类推。将页表条目中物理页号(Physical Page Number,PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是P字节的,所以物理页面偏移(Physical Page Offset,PPO)和VPO)是相同的。
图66 使用页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。如图67所示用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
图67 虚拟地址中用以访问TLB的组成部分
7.5 三级Cache支持下的物理内存访问
1、组选择:用虚拟地址中的组索引位作为索引,寻找相应位置。
2、行选择:选定组后,将虚拟地址中的标记位与选定组中的每一行进行比较。匹配上后,若有效位为1,则高速缓存命中。
3、字选择:选定目标行后,利用虚拟地址中的字偏移量即可选择所想选取的字。
4、若高速缓存未命中,则向低一层的存储结构寻找。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在进程中加载并运行hello需要以下几个步骤∶
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello文件中的.text 和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。图9-31概括了私有区域的不同映射。
- 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库libc. so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页(page fault)。图68展示了在缺页之前我们的示例页表的状态。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。
图68 VM缺页前
接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。图69展示了在缺页之后我们的示例页表的状态。
图69 缺页时状态
7.9动态存储分配管理
动态内存分配时,一般采用动态内存分配器,这样具有更好的移植性。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
(1)显式分配器,要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来.分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作。
(2) 隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
C标准库提供了一个称为malloc程序包的显式分配器。程序通过调用malloc函数来从堆中分配块。malloc函数返回一个指针,指向大小为至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐
如果 malloc 遇到问题(例如,程序要求的内存块比可用的虚拟内存还要大),那么它就返回NULL,并设置errno。malloc不初始化它返回的内存。那些想要已初始化的动态内存的应用程序可以使用calloc,calloc是一个基于malloc的瘦包装函数,它将分配的内存初始化为零。想要改变一个以前已分配块的大小,可以使用 realloc 函数。
7.10本章小结
本章主要介绍了hello的物理地址、存储地址、线性地址、逻辑地址的概念以及相互转变的步骤与操作。
同时介绍了cache的寻址,fork、execve函数的内存映像,缺页故障及管理以及动态内存分配。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
1、设备的模型化:文件
2、设备管理:unix io接口
这是一种将设备优雅地映射为文件的方式,允许Linux内核引出的一个简单、低级的应用接口。
8.2 简述Unix IO接口及其函数
1、打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
图70 打开文件
2、Linux shell创建的每个进程开始时都有三个打开的文件∶标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件< unistd.h>定义了常量STDIN_FILENO、STDOUTFILENO和STDERRFILENO,它们可用来代替显式的描述符值。
3、改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
4、读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k 开始,然后更新k。
5、关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
图71 关闭文件
8.3 printf的实现分析
1、printf在接受格式化命令后,会调用vsprintf和write函数,输出指定的匹配的参数。
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; } |
printf在接受格式化命令后,会调用vsprintf和write函数,输出指定的匹配的参数。
2、从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
3、字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
4、显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
1、当程序调用getchar函数时,程序将等待用户从键盘输入。
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; } |
2、异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
3、getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了Linux下IO设备的管理方法,对应的函数实现以及简单地分析了一下printf和getchar地工作原理。
结论
- hello的一生
- 程序员在IDE中编写hello的代码,将之存储为hello.c文件
- 预处理阶段:预处理器(cpp)处理hello.c中的预处理命令,将hello.c中涉及的内容扩展到文件中,转换为hello.i文件。
3、编译阶段:编译器(cc1)首先进行词法分析和语法分析,检查代码的规范性、是否有语法错误等。接下来对代码进行优化,最后生成汇编代码,将hello.i文件转变为hello.s文件,简化了将高级语言转化为计算机可执行的二进制文件时的操作。
4、汇编阶段:汇编器(as)将hello.s中用文本语言描述的指令逐一地翻译为二进制标识地机器指令,转化为hello.o。
5、链接阶段:链接器(ld)将hello所用到的静态链接库、动态链接库与hello.o链接形成可执行目标文件。
6、创建进程:操作系统控制shell调用fork函数为hello创建一个子进程。
7、加载程序:操作系统通过加载器,调用execve函数,将hello的代码、数据等信息加载到新进程中,并为其创建自己的虚拟内存空间。
8、运行:CPU,顺序执行hello的逻辑控制流中的指令,响应其需求。
9、异常:在运行过程中,OS同时接受和检测异常信号,并调用相关的异常处理程序,对各种异常信号进行处理。
10、hello的进程结束后,父进程回收子进程,hello的一生结束。
二、感悟
在学习计算机系统这门课之前,对于计算机,我的直观感受计算机就是一个神奇的黑盒,简单地点击鼠标就能做成很多事情,但实际上对于计算机的构成却一点都不了解。但通过学习该门课,我才了解原来一个简单的程序运行需要经过如此多繁琐的步骤,一个基本的信号传送就需要上百个硬件工作,软硬件交互。
可以说,学习了这门课不仅使我对于计算机的构成以及一些机制有了初步了解,便于编写出高效简洁的程序,在学习过程中还锻炼了我便对陌生问题,查找资料自主解决问题的能力。同时课程中穿插的实验,不仅及时地加深了我对课程内容的理解,还很好地锻炼了我的实践动手能力。
总而言之,计算机系统不仅给我带来了知识上的收获,还带领我走进了计算机的世界!
附件
名称 | 作用 |
hello.c | 源程序文件 |
hello.i | 预处理后的文本文件 (查看头文件的处理) |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的可重定位目标文件 (查看反汇编) |
hello | 链接后的可执行文件 (查看反汇编) |
可重定位目标文件ELF | 与hello.s对比分析编译的变化 |
可执行目标文件ELF | 与hello.o对比分析链接的变化 |
参考文献
[1] CSAPP
[2] 百度百科
[3] (13条消息) 线性地址、逻辑地址、虚拟地址、物理地址_hello&Code的博客-CSDN博客_线性地址