本文以hello.c程序为例,在分析过程中使用ubuntu作为操作系统,程序从原始.c文件经过预处理生成.i文件,又经历汇编生成.s文件、汇编生成.o文件,最后通过链接得到可执行文件。紧接着,Hello程序在系统中通过shell转化为进程被执行,执行过程中要考虑信号处理和获取内存数据时的寻址过程。此过程回顾了本学期计算机系统的几乎所有知识,使得对计算机系统的理解更加深入。
关键词:计算机系统;汇编;编译;链接;信号处理;内存寻址
目 录
第1章 概述
1.1 Hello简介
P2P:from Program to Process
指从程序hello.c变成进程process。GNU编译系统构造中,hello.c经过C预处理器cpp变为一个ASCII码的中间文件hello.i(展开头文件、处理宏定义、删除注释等),然后C编译器ccl将文件变为汇编文件hello.s(汇编代码),最后用汇编器as变成可重定位目标文件hello.o(机器码),最后经过链接器形成可执行目标程序(linux里格式是hello.out)。在shell中调用启动命令后,shell为其fork产生子进程,使得hello从程序转变为进程。
020:zero to zero
创建进程后shell用exceve函数找到程序入口,进程映射到虚拟内存空间后载入对应物理内存,从程序入口开始加载和运行,进入main函数执行目标代码。执行时,在CPU的分配下,指令进入CPU流水线执行。程序结束后shell父进程将回收hello进程,内核清除hello相关的数据结构,进程结束。
1.2 环境与工具
硬件环境:
处理器:13th Gen Intel(R) Core(TM) i5-13500H 2.50 GHz
机带RAM:7.7GB
系统类型:x86_64
软件环境:Windows11 64位,VMware,Ubuntu 20.04.1
开发和调试工具:Visual Studio 2022 64位;vim objump edb gcc readelf等工具
1.3 中间结果
hello.i 预处理后得到的文本文件
hello.s 编译后得到的汇编语言文件
hello.o 汇编后得到的可重定位目标文件
o_objdump.txt hello.o的反汇编代码
hello 经链接后得到的可执行文件
hello1.s hello的反汇编代码
elf.txt hello.o的elf文件
elf1.text hello的elf文件
1.4 本章小结
本章对hello程序运行的P2P和020过程进行了简单的介绍,主要介绍了Hello.c文件运行的全过程,从宏观上叙述了各个阶段的流程,列出了此次大作业所使用的相关工具和软硬件环境,最后介绍了文中所用到的文件和作用。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理步骤是指正式的编译阶段之前预处理器对源文件进行简单加工的过程,主要是根据已放置在文件中的预处理指令来修改源文件的内容。例如#include指令等预处理指令会把头文件的内容添加到.cpp文件中。此外,预处理器还会删除程序中的注释、多余空白字符、删除不会执行的if等。编译之前预处理可以适应不同的计算机和操作系统环境的限制,在许多情况下会将不同环境的代码放在同一个文件中,再在预处理阶段修改代码,使之适应当前的环境。
2.1.2预处理的作用
预处理过程中并不直接解析程序源代码的内容,而是对源代码进行相应的分割、处理和替换,主要有以下几个方面的作用:
宏定义指令(如#define a b):预编译将程序中除字符常量的所有a用b替换。#undef则对应取消某个宏的定义,以后该变量出现不再被替换。
条件编译指令(如#ifdef,#ifndef,#else,#elif,#endif):这些伪指令可使程序通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译会将用不到的代码删除。
头文件包含指令(如#include "FileName"或者#include):头文件一般用#define定义了大量的宏和各种外部符号的声明,采用头文件是为了某些定义可供多个C源程序使用。预编译程序将把头文件中的定义全部加入到.i文件中,方便编译程序进行处理。包含到c源程序中的头文件可以是系统提供的,在程序中#include它们要使用尖括号(< >),开发人自己定义自己的头文件在#include时要用双引号(“”)。
特殊符号:预编译程序可以识别一些特殊的符号,例如源程序中有LINE标识会被解释为当前行号(十进制数),FILE是当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
其他:如注释删除等
2.2在Ubuntu下预处理的命令
预处理的命令:gcc -E hello.c -o hello.i
图 1 预处理命令
如图1,输入命令后生成了hello.i文件。
2.3 Hello的预处理结果解析
图 2 hello.i开头
图 3 hello.i结尾
打开hello.i文件,原本短短几行hello.c代码得到了很大程度上的扩充,从十几行代码扩展到了3180行。从图2和图3(3167行至结尾)可以看出,主要是删除注释、扩展预处理指令扩展了几千行,源程序的其他部分都保持不变。
具体操作以#include<stdio.h>为例,cpp在系统头文件路径下查找stdio.h文件,一般在/usr/include目录下,然后将文件中的内容复制到hello.i中。stdio.h文件中可能还有其他的#include指令,这些头文件会被递归地展开到源文件中。
2.4 本章小结
本章介绍了hello.c的预处理过程,大致分析了预处理后形成的hello.i文件。仅24行的.c文件预处理后的文件竟有3000+行。如果编写一个hello程序需要3000行,这样的效率是极其低下的。这也就是预处理的意义:能让我们轻松写出可读性高,方便修改,利于调试的代码。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译是将预处理后的文本文件.i翻译为汇编语言的文本文件.s的翻译过程, 是一个汇编语言程序。
3.1.2编译的作用
将高级语言程序变为汇编语言,提高编程效率和可移植性。编译程序把一个源程序翻译成目标程序的工作过程分为六个阶段:词法分析、语法分析、语义分析、中间代码生成、代码优化和目标代码生成。词法分析和语法分析又称为源程序分析,分析过程中发现有语法错误,给出提示信息。语义分析主要是为了判断指令是否为合法的c语言指令,也叫做静态语义分析,不会判断一些在执行时可能出现的错误。中间代码使编译程序的逻辑更加明确,可保证代码优化的时候优化效果更好,之后根据用户指定的不同优化等级对代码进行安全的、等价的优化,可以提升代码在执行时的性能。经过上面的所有过程后,最终生成一个汇编语言代码文件。
3.2 在Ubuntu下编译的命令
图 4 编译过程
命令行:Gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1初始部分
图 5 节名称
.file是源文件,.text为代码节,.section .rodata表示只读数据段,.align 表示按x字节对齐内存地址,.string声明使用的两个字符串及其内容,.globl声明全局函数main,.type指定符号类型为函数(函数@function、数据@object),辅助链接器和调试工具理解代码结构。
3.3.2数据
- 常量
数字常量:存储在.text代码段,见图6。
图 6 数字常量示意,可见位于代码段
字符串常量:存储在.rodata只读数据段,见图7。
图 7 .rodata中的字符串常量
- 变量
局部变量:程序中局部变量为i和argc,如图8,从i++语句找到对应汇编代码,可知i被存放在栈上-4(%rbp)。
图 8 变量i存储方式
如图9,从argc!=5语句找到对应汇编指令,可知argc先被放入rdi寄存器,之后被存在栈上-20(%rbp)。
图 9 变量argc存储方式
char**argv数组的每个元素都是一个指向字符类型的指针。argv[]的首地址为-32(%rbp),将地址传递给寄存器%rax后通过增加%rax的值来实现对数组元素的访问。
图 10 取三个%s的过程
3.3.3赋值操作
程序中有对于变量i的赋值,可见先用movX指令赋初值(X=b(1字节),X=w(2字节),X=l(4字节),X=q(8字节)),然后i++使用addq语句每次+1。
图 11 i赋初值
图 12 i++
3.3.4类型转换
找到图13所示类型转换源代码和汇编代码。可见atoi函数将字符串转化为整型数。Linux下同样支持其他类型数据的相互转化。
图 13 类型转换相关代码
3.3.5算术操作
hello.c中的算术操作为for循环的每次循环结束后i++,该操作体现在汇编代码则使用指令addX实现,由于int为4字节,所以是addl。subX是减法操作,具体用法与addX类似。除此之外,Linux汇编语言中还支持其他的算数操作,如乘、除、算数移位、逻辑移位等。
图 14 加减汇编指令
3.3.6关系操作和控制转移指令
一共两处关系操作。分别是argc!=5和i<10,分别对应图15、图16的指令。可以看出,是cmpl+对应跳转指令。
控制转移与关系操作密切相连,往往紧接在关系操作之后。控制转移由cmpl和条件码来实现,je代表在相等时执行跳转;jle,代表在小于等于时执行跳转。
图 15 argc!=5
图 16 i<10
3.3.7数组/结构/指针操作
代码中出现的数组操作只有一个,即对于argv数组的操作,3.3.1中已经说明argv[]的首地址为-32(%rbp),argv[1]的储存地址是-24(%rbp),argv[2]的储存地址是-16(%rbp),argv[3]的储存地址是-8(%rbp),对于数组操作的汇编代码如下截图:
图 17 argv数组操作
3.3.8函数操作
首先要明确在X86系统中函数参数储存的规则,第1~6个参数依次储存在%rdi、%rsi、%rdx、%rcx、%r8、%r9这六个寄存器中,其余的参数保存在栈中的某些位置。
main函数:
参数:int argc,char*argv[],其中argv储存在栈中,argc储存在%rdi中。argc
表示命令行参数的数量(程序名也要算在参数数量里),argv[]是指向命令行参数字符串的指针数组。
函数调用:将命令行参数解析为 argc 和 argv,将控制权转移给 main 函数,并传递这两个参数。运行main函数时用call调用函数,并将要调用的函数地址数据写入栈中,然后自动跳转到这个调用函数内部。main函数里调用了puts、printf、sleep、atoi、getchar、exit函数。main 返回的整数(通常为 0 表示成功,非零表示错误)会作为程序的退出状态码返回给操作系统。
图 18 main函数
printf函数:
参数:argv[]首地址。
函数调用:该函数调用了两次。第一次是在参数数量不足时输出错误信息,第二次是在参数满足条件时格式化输出用户提供的参数。
图 19 第一次调用
第一次调用时,程序首先检查argc是否为5,如果参数个数不足,加载 .LC0 处的字符串(具体内容见图7)并调用printf 的简化版本puts,自动在字符串末尾添加换行符。输出错误信息后,调用 exit(1) 终止程序。
图 20 第二次调用
第二次调用,分别用%rax传递参数,把argv[1]、argv[2]、argv[3]分别存进%rsi、%rdx、%rcx,%rdi存储格式化字符串 "Hello %s %s %s\n"(.LC1)。输出时printf 从 .LC1 读取格式字符串 Hello %s %s %s\n,并依次替换为三个参数。
exit函数:
参数:对于 exit(int status):status 的值被放入 %edi 寄存器,如图21。
图 21 定义status的值
函数调用:当参数不满足条件时,程序会调用 exit(1) 终止并返回错误状态码,或是主逻辑执行完毕后,通过 return 0 隐式调用 exit(0)。
atoi函数:
参数:atoi 接收一个字符串参数(const char*),从图22可看出此参数通过 %rdi 传递,而%rdi从-32(%rbp)+32来看,又实际上是存储字符串的位置(见本节printf函数第二次调用时的说明)。
图 22 调用atoi函数
函数调用:atoi 的返回值(int)默认存放在 %eax 寄存器中。
sleep函数:
参数:sleep 接收无符号整数参数(秒数),由图23知该参数通过 %edi传递,由于该代码和图22直接相邻,所以是atoi的函数返回值被传递给 sleep 函数作为参数。
图 23 调用sleep函数
函数调用:无返回值,sleep的功能是暂停参数所代表的秒数。
getchar函数:
参数:无参数。
图 24 getchar调用
函数调用:从标准输入读取一个字符,返回其ASCII码值。若输入缓冲区为空,getchar会阻塞程序执行,直到用户输入字符并按下回车键。成功时返回读取的字符( int 类型)并存在%eax里。
3.4 本章小结
本节主要介绍编译器通过编译由.i文件生成汇编语言的.s文件的过程,并分析了变量,赋值,循环和各种基础函数的C语言基本语句的汇编表示。即使是高级语言中一个简单的条件语句或者循环语句在汇编语言中都需要涉及到更多步骤来实现。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
把汇编语言翻译成机器语言的过程称为汇编,汇编器(as)将.s文件翻译为机器语言并把指令打包成为可重定位目标文件,生成二进制文件.o。
4.1.2汇编的作用
将汇编代码转化为计算机能够完全理解的机器代码/二进制代码。
4.2 在Ubuntu下汇编的命令
预处理的命令:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
图 25 汇编过程
4.3 可重定位目标elf格式
4.3.1生成ELF文件
得到elf文件指令:readelf -a hello.o > ./elf.txt
图 26 得到elf文件
4.3.2 ELF文件各部分内容
ELF头:
以一个16字节的序列开始,这个序列描述了生成该文件系统下的字的大小以及一些其他信息。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。本文件中ELF头部分分析如下:
“Magic 7F 45 4C 46”即0x7F 'E' 'L' 'F',是ELF文件的标识符,所有ELF文件以此开头。“ELF64”表示这是64位文件。“2补码,小端序”指文件中的负数使用 2 补码表示,数值在内存中以低字节在前的小端方式存储。类型为REL (可重定位文件),说明这是一个未链接的目标文件(.o 文件本身就是未链接的目标文件)。“入口点地址0x0”因为未链接所以尚未确定程序入口地址。从程序头表(Program Headers)起点0 字节,数量0,大小0 可知此文件没有程序头表(程序头仅存在于可执行文件或共享库中)。从节头表(Section Header)起点在1088 字节,数量14 个,每个大小64 字节可知.text、.data等节的信息。节头字符串表索引(Section Header string table index)值为13,可知存储节名的表在第13节。
图 27 ELF头
节头:
节0占位符无实际内容;节1是代码段(.text)共163字节(0xa3);节2是rela.text 保存了.text中指令的重定位信息(如外部函数调用地址),为了在链接时高效处理,.rela.text 通常紧挨着.text 节放置;节3、节4数据段(.data已初始化的全局变量 和 .bss未初始化的全局变量)大小为 0,当前均未使用;节5是只读数据段.rodata(用于存字符串等),本文件中为64 字节的常量;节11符号表.symtab
,大小是264 字节(0x108),每个条目为24字节(0x18 ),说明共有 264 / 24 = 11 个符号。
图 28 节头
重定位节:
记录了ELF文件中需要修正的地址信息,确保代码能正确引用外部符号和内部数据。每个重定位条目包含偏移量(需修正的地址在目标节.text/.eh_frame中偏移量);信息(高32位为符号索引(指向 .symtab),低32位为类型);符号值(符号的实际地址,链接前为0);符号名称+加数(要引用的符号名及额外的偏移量)。
图 29 重定位节
符号表:
.symtab节中包含ELF符号表,这张符号表包含一个条目的数组,存放一个程序定义和引用的全局变量和函数的信息。
图 30 符号表
4.4 Hello.o的结果解析
4.4.1得到反汇编代码
得到反汇编的命令:objdump -d -r hello.o,反汇编代码如下:
图 31 反汇编代码
4.4.2与hel1o.s的对照分析
机器语言的构成:
机器语言是CPU直接执行的二进制指令集,由操作码、操作数(可选)和指令格式三部分组成。
与汇编语言的映射关系:
操作码用来表示指令的操作类型,是1-3字节的二进制数,部分指令需要前缀字节扩展功能。操作数是指定指令的操作对象,根据类型可分为立即数、寄存器和内存地址。指令格式包括前缀(可选)和操作码,前缀如段前缀、操作数大小前缀(如0x66切换16/32位操作数)等。操作码占1-2字节,指定指令类型。以3f: 48 83 c0 18 add $0x18,%rax一行为例:
十六进制数字 | 字段类型 | 作用描述 |
48 | 操作码前缀 | 64 位操作数前缀(% rax,否则默认是32位%eax) |
83 | 操作码主体 | add 指令(加立即数) |
c0 | ModR/M 字节 | 编码目标寄存器和操作数类型 |
18 | 立即数操作数 | 0x18(十进制 24) |
表 1 机器代码解析
有一些指令的指令类型被省略了,如图32,q都被省略。
图 32 movq和addq变为mov和add
分支转移:
对于条件跳转,hello.s中跳转地址用的是.L1、.L2等段的名字,hello.o跳转命令后则是目标地址。
图 33 地址格式的改变
函数调用:
反汇编文件中对函数的调用与重定位条目相对应。和jmp类似,hello.s中call指令后是函数名称,hello.o反汇编代码中call后是main函数的相对偏移地址。
图 34 地址表示变化
同时还能看到反汇编中调用函数的机器码里函数的相对地址为0,因为再链接生成可执行文件后才会生成其确定的地址,所以这里的相对地址都用0代替。
数制转换:
如图32,hello.s数字表示是十进制,hello.o反汇编代码数字表示为十六进制。
图 35 进制变化
4.5 本章小结
本章对汇编过程从汇编的概念与作用、汇编命令、可重定位目标elf格式等进行了分析与阐述,通过与hello.s的反汇编代码的比较,更加深入地理解了在汇编过程中发生的变化,为下一步链接做好了准备。
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
将各种不同文件的代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也可以执行于加载时,分别对应静态链接和动态链接。
5.1.2链接的作用
把预编译好了的若干目标文件合并成为一个可执行目标文件。使得分离编译称为可能,一个大型的应用程序可以被分解为模块,这样程序需要修改或者调试时只需要单独修改和编译对应模块,然后重新链接,不用全部重新编译。
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
图 36 链接生成可执行文件
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
5.3 可执行目标文件hello的格式
5.3.1生成ELF文件
得到elf文件命令:readelf -a hello > elf1.txt
图 37 hello对应elf文件
5.3.2 ELF文件各部分内容
ELF头:
和.elf(见图27)相比,类型从REL(可重定位文件)变为EXEC(可执行文件),其他同.elf一致,入口点地址从0变为了具体地址(0x4010f0),程序头起点从0变为了64,表示程序头在文件内的偏移量为64字节,程序头包含加载程序所需信息。节头在文件内的偏移量(Start of section headers)从1088变为13536字节,每个程序头的大小和数量从0变为56字节(Size of program headers),程序头数量从0变为12个(Number of program headers),节头的数量(Number of section headers)从14变为27个,节头字符串表在节中的索引从13变为26(Section header string table index),用于查找节名等字符串信息。由此可见主要是程序头的初始化。
图 38 ELF头
节头:
节数增加,每个节中都指定了一个类型,定义了节数据的语义,如PROGBITS(程序必须解释的信息,比如二进制代码)、SYMTAB(符号表)、REL(重定位信息)。显示了每个节中的大小、在虚拟内存中的位置、在二进制文件内部的偏移量等信息。Flags代表如何访问或处理,例如代码段可读可执行、数据段可读可写、只读数据段只读不可写等。
图 39 节头
程序头:
程序头是ELF文件中用于描述文件如何被加载和执行的结构 ,它包含了一系列的程序头表项(Program Header Entry),每个表项描述了一个段(Segment)的相关属性。程序头表主要是告诉加载器如何创建进程镜像,将ELF文件中的内容映射到内存中,确定哪些段需要加载、加载到内存的什么位置、具有怎样的权限等,从而保证程序能够正确执行。
图 40 程序头
Dynamic section:
表示动态节,与动态链接紧密相关。当一个目标文件参与动态链接时,它的程序头表会存在一个类型为 PT_DYNAMIC 的元素 ,该 “段” 就包含了.dynamic section 。它有一个特殊符号 _DYNAMIC 来标识,内部包含了一系列特定结构的数组。Dynamic section为动态链接器提供关键信息,使其能够确定程序依赖的共享库,找到共享库中符号的位置,并在运行时将程序中未解析的符号与共享库中的符号正确链接起来。
图 41 Dynamic section
重定位节:
和.o文件比信息更全面和具体,不仅有内部符号重定位信息,还包含动态链接相关动态库符号详细重定位信息,如动态库名、库内符号名、符号在库中的具体位置等,以满足程序运行时动态链接需求。
图 42 重定位节
Symbol table:
保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。Name表符号名称;Value表符号相对于目标节起始位置偏移;Size表目标的大小;Type表类型,如全局变量或函数;Bind表是本地的还是全局的。
图 43 Symbol table
5.4 hello的虚拟地址空间
如图44,通过程序头中的LOAD知道可加载的程序段的地址为0x400000。
图 44 可加载程序段地址
使用edb打开hello可执行文件,在edb的Data Dump窗口看到hello的虚拟地址空间分配的情况,如图45所示:
图 45 Data Dump
从elf1.text可知.text段从0x4010f0开始,偏移量0x10f0,长度为216字节(0xd8),包含程序的可执行指令。0x4010f0+0xd8=0x4011c8,得出.text段所在处。
图 46 .text偏移量
.data段从0x404030开始,偏移量0x3030,长度为4字节(0x04),edb找到对应地址后可看到对应地址存储printf输出格式。
图 47 .data偏移量
图 46.data对应地址存储
其他节数情况类似。
图 47 .rodata对应情况
图 48 .symtab对应情况
5.5 链接的重定位过程分析
5.5.1生成反汇编文件
得到反汇编的命令:objdump -d -r hello > hello1.s
图 49 生成hello1.s
5.5.2 hello与hello.o的不同
函数链接:
相比于hello.o得到的反汇编代码,hello的反汇编代码行数增加,其中加入了代码中调用的getchar,printf,exit等库函数,同时每一个函数都有了相应的虚拟地址。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。
图 50 函数代码
地址声明:
链接完成之后,hello中的所有对于地址的访问或是引用都调用的是虚拟地址。以call函数和jle为例,call函数后从相对main地址变成了绝对地址:
图 51 call函数地址
jle函数情况相同:
图 52 jle后地址
5.5.3 重定位过程
链接主要分为符号解析和重定位两个过程。符号解析将每个符号引用和一个符号定义关联起来,链接器将所有相同类型的节合并为同一类型的聚合节。然后链接器将运行时的内存地址赋给新的聚合节及输入模块定义的每个符号。至此程序中每条指令和全局变量都有唯一的运行内存地址。从0开始生成代码段和数据段,链接器把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向对应内存位置。
5.6 hello的执行流程
使用gdb调试hello,初始操作如图53所示,set verbose on启用详细输出;set trace-commands on跟踪命令执行;set pagination off关闭分页;set breakpoint pending on允许暂挂断点;catch syscall execve捕获execve系统调用;其余为函数断点。
图 53 调试设置断点
用run指令运行程序:
图 54 start,main和exit
可见GDB先启动位于/home/emma/hello路径的目标程序,之后三行是读取程序依赖的动态链接库(像ld-linux-x86-64.so.2)的符号信息。程序在_start()函数处触发了断点,这个函数位于动态链接器ld-linux-x86-64.so.2中。_start()是程序执行的起始点,在进入main()函数之前会先执行它。之后进入main函数,最后结束到exit函数。
图 55 循环里的printf,atoi和sleep
调用main以后会有printf,atoi和sleep的循环(对应for循环)
图 56 出循环以后的getchar
最后一次循环结束后调用getchar函数,最后是exit函数。
5.7 Hello的动态链接分析
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。如图57,根据elf1.text可知GOT起始表位置为0x403fe8。
图 57 GOT起始地址
在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址,0x403fe8+0x8后的16个字节均为0:
图 58 初始情况
调用dl_init后字节改变:
图 59 链接后情况
5.8 本章小结
在链接过程中,各种代码和数据片段收集并组合为一个单一文件。利用链接器,源文件被分解为更小的管理模块,在应用时将它们链接就可以完成一个完整的任务。本章主要介绍了链接器如何将hello.o可重定向文件与动态库函数链接起来,分析了可重定位文件与可执行文件ELF的差异,并分析了重定位的过程、执行过程和动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
当一个程序被加载到内存中并开始执行时,它就成为了一个进程。进程是操作系统进行资源分配和调度的基本单位,它包含了程序执行所需的所有资源和状态信息。进程的组成包括程序代码、数据、进程控制块、系统资源等。
6.1.2进程的作用
实现多任务并发执行,在现代系统上运行一个程序时,用户会得到一个假象,好像程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用
Shell是用户与操作系统内核之间的接口程序,其接收用户输入的命令,将其翻译为内核能理解的指令并返回执行结果。Shell有多种实现, Bash是Linux 和macOS系统默认的Shell,也是最广泛使用的一种。
6.2.2 Shell-bash的处理流程
先读取输入的命令行,解析命令后判断是内置命令还是外部命令,内置命令就直接Bash内部实现( cd、echo、export等),外部命令就通过fork创建一个新的子进程,在 PATH 路径中查找对应的可执行文件,通过 execve() 系统调用执行。判断该程序为前台程序还是后台程序,前台程序需等待程序执行结束,后台程序继续接受新命令。
6.3 Hello的fork进程创建过程
终端程序通过调用fork()函数创建一个子进程,操作系统分配新的进程资源(如PID、内存空间,PID是识别不同进程的最重要标志)。子进程得到与父进程完全相同但是独立的一个副本,包括.text、.data、共享库以及用户栈等(现代系统通常使用写时复制技术,避免实际复制,提高效率)。子进程还有与父进程打开文件描述符相同的副本权限。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
在hello中,输入指令 ./hello 学号 姓名 电话号 秒数后,shell对输入的命令进行解析,又由于输入命令不是内置命令,所以shell会调用fork()创建一个子进程。
6.4 Hello的execve过程
在Linux系统中,execve()表示在当前进程的上下文中加载并运行一个新程序。与 fork() 的区别是execve() 会替换当前进程的整个地址空间(代码、数据、堆栈等),但保留PID和打开的文件描述符。这意味着调用 execve() 后,原进程的代码不会继续执行,而是跳转到新程序的入口点(通常是 main() 函数)。只有当出现错误时,exceve才会返回到调用程序。与fork一次调用返回两次不同,exceve调用一次并从不返回。
6.5 Hello的进程执行
hello进程的执行是依赖于进程所提供的抽象的基础上,下面阐述操作系统所提供的的进程抽象:
逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。多个流并发的执行的一般现象成为并发。
时间片:一个进程执行它的控制流的一部分的每一时间段。进程连续执行的最大时间决定了系统的响应性和吞吐量。
私有地址空间:操作系统为每个进程分配独立的内存空间、文件描述符等资源,确保进程正常运行。同时进程之间的资源相互隔离,一个进程的崩溃通常不会影响其他进程。这种隔离性提高了系统的稳定性和安全性。
用户模式和内核模式:处理器通常使用某个控制寄存器中的一个模式位来提供这种功能。当没有设置模式位时,进程就处于用户模式中;设置模式位时,进程处于内核模式。
维度 | 用户模式 | 内核模式 |
特权级别 | 低 | 高 |
可访问内存 | 仅限用户空间 | 全部内存(用户空间 + 内核空间) |
可执行指令 | 非特权指令 | 所有指令(包括特权指令) |
典型载体 | 应用程序 | 操作系统内核、设备驱动程序 |
切换方式 | 通过系统调用、中断、异常 | 通过中断返回指令 |
表 2 用户模式和内核模式的区别
上下文信息和切换:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。内核调度一个新的进程运行后,通过上下文切换机制来转移控制到新的进程。这时就存在如图60所示的用户态与核心态的转换。
图 60 上下文切换
总的来说包含三步:保存以前进程的上下文、恢复新恢复进程被保存的上下文,以及将控制传递给这个新恢复的进程。
结合hello分析:进程调用execve函数后,shell为hello分配了新的虚拟的地址空间,并且将hello的.txt和.data分配虚拟地址空间的代码区和数据区。最初hello运行在用户模式下,输出hello ./hello 学号 姓名 电话号 秒数,然后调用sleep函数,进程陷入内核模式,内核处理休眠请求,主动释放当前进程。将hello进程从运行队列放入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,定时器到时会发送中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列重新进入运行队列,hello进程继续进行自己的控制逻辑流。
6.6 hello的异常与信号处理
异常分类:
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
表 3 异常分类
图 61 可能的信号
异常处理方式:
如图62所示。
图 62 各类异常方式
运行结果以及相关命令:
程序正常运行时,打印10次信息,以输入回车为标志结束程序并回收进程。
图 63 程序正常运行
Ctrl-C:对应SIGINT信号,程序结束并回收进程。(说明:由于0s时未输入指令程序已经执行完毕,此处将秒数0改为1,后续不再做阐述)
图 64 Ctryl-C
Ctrl-Z:对应SIGSTP信号,Shell显示屏幕提示信息并挂起进程。用ps和jobs可看到进程被挂起,使用fg进程继续执行。
图 65 Ctrl-Z
图 66 ps和jobs
图 67 fg
pstree:将所有进程以树状图显示。
图 68 pstree
kill可以杀死指定(进程组的)进程。
图 69 kill进程
不停乱按:发现只是作为字符串缓存到stdin,只有getchar读到’\n’结尾的字串才能使hello结束,之后stdin中的其他字串会当做Shell的命令行输入。
图 70 不停乱按
6.7本章小结
在本章中阐述了进程的定义与作用,介绍了shell的一般处理流程和作用,并且着重分析了调用fork创建新进程,调用execve函数执行hello,hello的进程执行,以及hello 的异常与信号处理等方面。程序的高效运行离不开异常、信号、进程等概念,正是这些机制支持hello能够顺利地在计算机上运行。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
是早期x86架构为支持分段内存管理而设计的概念,访问指令给出的地址(操作数)叫逻辑地址,也叫相对地址,由段选择符和段内偏移量两部分组成,要经过寻址方式的计算或变换才得到内存储器中的物理地址。在hello程序中,编译器在编译时将代码和数据划分为代码段、数据段等不同的段。例如,main 函数中的指令可能位于代码段偏移量 0x1234 处。
7.1.2线性地址
线性地址是逻辑地址经过分段机制转换后得到的中间地址,程序hello的逻辑地址在分段部件中是段中的偏移地址,这个值加上基地址就变成了线性地址。
7.1.3虚拟地址
虚拟地址是现代操作系统为每个进程提供的独立地址空间。每个进程都认为自己拥有连续的、从 0x0 开始的完整内存空间,但实际上这些地址会通过分页机制映射到物理内存的不同位置。在GDB中查看hello程序中各种函数的地址是虚拟地址。操作系统通过页表将虚拟地址映射到物理地址。
7.1.4物理地址
物理地址是内存芯片实际使用的地址,直接对应 DRAM 中存储单元的物理位置。CPU 通过内存总线将物理地址发送给内存控制器,以访问实际的物理内存。
例如当程序执行时,main 函数可能被加载到物理地址 0x10000000 处。物理地址对用户程序不可见,由操作系统和硬件 MMU(内存管理单元)负责映射和管理。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式内存管理是早期操作系统实现内存隔离和保护的核心机制。它通过将内存划分为多个逻辑段,使用逻辑地址到线性地址的转换实现了程序与数据的分离和访问控制。
图 68 段地址结构
分段系统的逻辑地址结构由段号(段名)和段内地址(段内偏移量)所组成。段号前13位是一个索引号,后3位为一些硬件细节,可以通过段号前13位查找内存中的段表。段表中有此段在物理内存中的存放地址,因此可以得到段的首地址加上段内地址,得到实际的物理地址。这个过程也是由处理器的硬件直接完成的,操作系统只需在进程切换时,将进程段表的首地址装入处理器的特定寄存器当中。这个寄存器一般被称作段表地址寄存器。
整个系统只有一个全局描述符表,它包含了各任务的段。每个任务程序的段包含其独属的代码段、数据段、堆栈段等。
图 69 段表情况
7.3 Hello的线性地址到物理地址的变换-页式管理
操作系统会通过页式管理将程序的线性地址转换为物理地址。假设由逻辑地址得到的线性地址一共32位,前10位是页目录索引,中间10位是页表索引,最后12位是页内偏移量,由CR3寄存器可以得到页目录基地址,再得到页目录项,由此可知需要的高位地址存在哪张页表里,用前10位拿到的页表基地址加上中间10位可以找到对应页表项,最后和页内偏移量合在一起得到真正的物理地址。
图 70 线性地址变物理地址
7.4 TLB与四级页表支持下的VA到PA的变换
如图71,可知首先CPU生成一个虚拟地址访问内存,首先虚拟地址VA本身由VPN和VPO构成,暂时用不到页内偏移量VPO,只看VPN,先去找快表TLB里面有没有VPN对应的物理地址,用TLB的时候又会把VPN分成TLBT和TLBI,TLBI是组相联映射的时候把整个TLB分成了多少组,在此图里四路组相联说的是每组里面有四个块,一共64块,所以一共16组。TLBI要能表示出来每个组。所以取4位。用TLBI找到对应的组以后TLBT和组里所有单元比较,找到一样的直接取走对应PPN,和VPO连在一起就构成了整个物理地址PA。
如果在TLB里没命中,那就要如7.3所述那样,在这个机器里,VPN又被分成四个小的VPN1~4,先去由CR3寄存器找该VPN的页目录基地址,此时只用到了VPN1,第一层页表里对应的地址是第二层页表的首地址,假设VPN1对应的第一层页表的地址是T2,用T2和VPN2找到第二个页表里面对应的位置,这个位置里的地址记作T3,一共四层页表,最后一层页表提取出来的就是物理地址的VPN部分,同样和VPN合在一起得到整个物理地址PA。
图 71 四级页表地址变换
7.5 三级Cache支持下的物理内存访问
存储器层次结构如图72所示。
图 72 存储器层次结构
cache映射内存地址有直接映射、全相联映射和组相联映射三种,一般用组相联映射,访问物理内存过程如下:
从L1开始用VPN的Index位找到对应的组,在组中通过Tag位和组里所有块进行比较,匹配且有效位为1时说明命中,然后把VPN换成PPN得到物理地址,将地址中数据取出返回给CPU,完成访存。若未命中,则去L2进行相同操作。如果L2命中了,返回数据时可能将数据提升到 L1 Cache(取决于计算机的写分配策略),未命中再去L3查找,之后操作与前面相似。
图 73 cache的三种映射方式
7.6 hello进程fork时的内存映射
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
图 74 写时复制
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件hello中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:
1)删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存 在的区域结构;
2)映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零;
3)映射共享区域,hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4)设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
当指令引用一个相应的虚拟地址,而与改地址相应的物理页面不再内存中,会触发缺页故障。通过查询页表PTE可以知道虚拟页在磁盘的位置。缺页处理程序从指定的位置加载页面到物理内存中,并更新PTE。然后控制返回给引起缺页故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中,因此指令可以没有故障的运行完成。故障处理具体流程如图63。
图 75 故障处理流程
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) 。堆紧接在未初始化的数据区域后开始,并向更高的地址生长。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块是一个已分配或空闲的连续的虚拟内存片。已分配的块显式地保留为供应用程序使用。空闲块可显式地被应用分配,已分配的块被释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
显式分配器要求应用显式地释放任何已分配的块。如Printf会调用malloc,C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。隐式分配器也叫做垃圾收集器,如果分配器检测到一个已分配块何时不再被程序所使用,那么就释放这个块。自动释放未使用的已分配的块的过程叫做垃圾收集。
隐式空闲链表的内存分配:当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。分配器搜索方式的常见策略是首次适配、下一次适配和最佳适配。一旦找到一个匹配的空闲块,就必须做一个另策决定,那就是分配这个块多少空间。分配器通常将空闲块分割为两部分。第一部分变为了已分配块,第二部分变为了空闲块。如果分配器不能为请求块找到空闲块,可以合并那些在物理内存上相邻的空闲块,如果这样还不能生成一个足够大的块,分配器会调用sbrk函数,向内核请求额外的内存。
合并空闲块合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,只需要通过改变头部的信息就能完成合并空闲块。
然而,因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表并不适合。一种更好的方法是将空闲块组织为某种形式的显式数据结构。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如图所示:
图 76 前驱后继指针
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,例如用后进先出的顺序维护链表,将新释放的块放置在链表的开始处,分配器会最先检査最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章主要介绍了hello的存储器的地址空间,介绍了四种地址空间的差别和地址的相互转换。同时介绍了hello的四级页表的虚拟地址空间到物理地址的转换。阐述了三级cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、在发生缺页异常的时候系统将会如何处理这一异常。最后介绍了动态内存分配的作用以及部分方法与策略。
结论
1生成阶段见图77。
图 77 hello执行程序生成过程
2运行阶段,输入./hello ./hello 学号 姓名 电话号 秒数,终端判断输入的指令不是内置指令,fork()创建一个新的子进程,然后调用execve()启动加载器,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数。argc是含hello一共的输入参数,argv是存放字符串的数组。访问内存时需要MU将程序中使用的虚拟内存地址通过页表映射成物理地址。hello调用sleep函数之后进程陷入内核模式,处理休眠请求主动释放当前进程,内核进行上下文切换将当前进程的控制权交给其他进程,当sleep函数调用完成时,内核执行上下文切换将控制传递给当前进程。hello执行printf函数时会调用malloc 向动态内存分配器申请堆中的内存。
3信号影响,当程序在运行的时候输入Ctrl-C,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl-Z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构。
感悟:
计算机系统的设计思想和实现都是基于抽象实现的。从最底层的信息的表示用二进制表示抽象开始,到实现操作系统管理硬件的抽象。从源代码到程序执行中间经过了复杂的过程,硬件和软件的分工和对应让人更加感觉到计算机系统的设计精巧。
附件
文件名 | 功能 |
hello.c | 源程序 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
o_objdump.txt | hello.o的反汇编代码 |
hello | 经链接后得到的可执行文件 |
hello1.s | hello的反汇编代码 |
elf.txt | hello.o的elf文件 |
elf1.text | hello的elf文件 |
表 4 中间文件表单
参考文献
[1] https://www.cnblogs.com/diaohaiwei/p/5094959.html
[2] 深入理解计算机系统原书第3版
[3] https://www.cnblogs.com/pianist/p/3315801.html
[4]https://blog.csdn.net/weixin_45406155/article/details/103775420
[5]https://blog.csdn.net/m0_51731232/article/details/118186831?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522165209688416781685362692%2522%252C%2522scm%2522%253A%252220140713.130102334…%2522%257D&request_id=165209688416781685362692&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-2-118186831-null-null.142^v9^control,157^v4^new_style&utm_term=%E5%93%88%E5%B7%A5%E5%A4%A7csapp%E5%A4%A7%E4%BD%9C%E4%B8%9A&spm=1018.2226.3001.4187
转载自CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2301_79548994/article/details/148004278