目录
Visual Studio 2022 64位;CodeBlocks 64位;vi/vim/gedit+gcc
逻辑地址由两部分组成:段标识符、段内偏移量。段标识符是由一个16位长 的字段组成,称为段选择符,段选择符用16位段寄存器存放。
1.printf函数:int printf(const char *fmt, ...)
va_list arg = (va_list)((char*)(&fmt) + 4);
计算机系统
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 信息安全
学 号 2022111318
班 级 2203202
学 生 杨博远
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
在信息时代的浪潮中,计算机系统的每一个环节都充满了神秘和精彩。本文通过对Hello程序的深入剖析,带领读者踏上一段从源代码到可执行文件,再到实际运行的奇幻旅程。我们的目标是揭开计算机系统的神秘面纱,展示其中蕴含的复杂技术和精妙实现。
首先,我们将Hello程序作为探险的起点,详细介绍了其基本信息及研究环境与工具。预处理阶段如同魔法般,将代码中的指令和宏进行初步转换和优化。接着,编译阶段是一次蜕变的过程,编译器将人类可读的C语言代码变成了汇编语言,就像将咒语转化为魔法符文。汇编阶段则将这些符文进一步凝练成机器能够理解的指令,生成可重定位的目标文件。
链接阶段犹如搭建一座桥梁,将分散的目标文件连接成一个整体,形成最终的可执行文件。在这个过程中,我们揭示了ELF格式的神奇构造和虚拟地址空间的奥秘。运行阶段更是充满了惊险和挑战,进程管理、内存管理、I/O管理等环节,展现了计算机系统在处理复杂任务时的高超技艺。Hello程序的进程创建、执行、异常处理和信号处理等过程,如同一场场精心编排的舞台剧,精彩纷呈。
通过本文的研究,我们不仅掌握了计算机系统各环节的理论知识,更在实际操作中积累了宝贵的经验。这段探险之旅,不仅揭示了计算机系统的工作原理,还为未来的系统设计和开发提供了丰富的灵感和借鉴。本文的创新之处在于将各个阶段的分析有机融合,形成了一条完整的知识链,为读者提供了从理论到实践的全面指南。
关键词:计算机系统,预处理,编译,汇编,链接,进程管理,内存管理,I/O管理
目 录
第1章 概述
1.1 Hello简介
1.1.1 P2P:
hello程序的生命周期是从一个高级C语言程序开始的。为了在系统上运行hello.c程序,hello.c首先经过预处理器(cpp)得到修改了的源程序hello.i;接着,编译器(cc1)将其翻译为汇编程序hello.s;然后经过汇编器(as)翻译成机器语言指令,把这些指令打包成可重定位目标程序hello.o;接下来,经过链接器,将调用的标准C库中的函数(如printf等)对应的预编译好了的目标文件以某种方式合并到hello.o文件中,得到可执行目标程序hello。当我们运行时,在shell中利用fork()函数创建子进程,再用execve加载hello程序,这时,hello就由程序(program)变成了一个进程(process),完成了P2P的过程。
图 1.1
1.1.2 020:
在shell中用fork()函数创建子进程,再用execve加载可执行目标程序hello,映射虚拟内存,程序开始时载入物理内存,进入CPU处理。CPU为执行文件hello分配时间片,进行取指、译码、执行等流水线操作。内存管理器和CPU在执行过程中通过L1、L2、L3三级缓存和TLB多级页表在物理内存中取的数据,通过I\O系统根据代码指令进行输出。在程序运行结束后,父进程会对其进行回收,内核把它从系统中清除,这时hello就由0转换为0,完成了020的过程。
1.2 环境与工具
1.2.1 硬件环境
Intel Core7 12700H;2.30GHz;16G RAM;1T SSD
1.2.2 软件环境
Windows11 64位;Vmware 17;Ubuntu 20.04 LTS 64位
1.2.3 开发工具
Visual Studio 2022 64位;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
hello.i 预处理产生的文件
hello.s 编译产生的汇编代码文件
hello.o 汇编产生的可重定位目标文件
hello.o.elf hello.o文件的ELF格式
hello.o.asm hello.o反汇编生成的文本文件
hello 链接产生的可执行目标文件
hello.elf hello文件的ELF格式
hello.asm hello反汇编生成的文本文件
1.4 本章小结
本章简单介绍了hello程序P2P(From Program to Process)及020(From Zero-0 to Zero-0)的过程,并说明了本次大作业所需的硬件环境、软件环境以及开发工具和中间生成的文件信息。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理是指在编译过程中,对源代码进行一些预处理操作,以便于编译器更好地理解和处理源代码。预处理器通常是一个单独的程序,它在编译器之前对源代码进行处理。预处理器根据源程序前面的#include指令引用的库,检索并包含这些库文件的代码,从而使编译器能够使用这些头文件中的函数和变量。
C语言的预处理主要内容:
1.宏定义(#define 标识符 文本): 宏定义用于将一个标识符替换为特定的文本。这种替换在预处理阶段完成,可以用来简化代码,提高可读性和可维护性。
2.文件包含(#include "文件名"): 文件包含指令用于将指定文件的内容插入到当前文件中。这种方式使得程序员可以将公共代码或声明放在头文件中,以便在多个源文件中共享。
3.条件编译(#ifdef,#else,#endif): 条件编译允许程序员根据不同的条件选择性地编译代码。这种机制在跨平台开发和调试过程中非常有用,因为它可以根据不同的编译条件生成不同的代码。
程序预处理的作用:
1. 通过#define指令,预处理器可以将常用的代码片段定义为宏,以便在代码中多次使用。宏定义不仅减少了代码量,还提高了代码的可读性和可维护性。预处理器会在编译前将源程序中的宏替换为相应的宏常量。例如:
图2.2.1
2. 条件编译:条件编译指令允许根据不同的条件选择性地编译代码。这样可以根据不同的需求和环境生成不同版本的程序,过滤掉不需要的代码,从而避免程序冗余。例如:
图2.1.2
3. 处理文件包含:通过#include指令,预处理器将头文件中的代码插入到源代码中。这使得程序员可以在源代码中使用头文件中定义的函数和变量,增强代码的模块化和可维护性。例如:
图2.1.3
4. 编译器指令:预处理器可以使用编译器指令来告诉编译器如何处理源代码。例如,#pragma指令可以用于控制编译器的特定行为,如优化、警告处理等。例如:
图2.1.4
5.预处理器在处理源代码时,会删除所有的注释,使得编译器在编译过程中不受注释内容的干扰。例如:
图2.1.5
程序预处理是C语言编译过程中至关重要的一步。通过宏定义、文件包含和条件编译等机制,预处理器不仅简化了代码编写和维护,还提高了代码的可移植性和执行效率。此外,预处理器还能通过编译器指令优化代码和删除注释,使得源代码更加清晰易读。理解和熟练运用预处理器指令是编写高质量C程序的基本技能之一。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
图2.2.1 输入预处理命令
2.3 Hello的预处理结果解析
在ubuntu中的终端命令行输入gcc -E hello.c -o hello.i,得到hello.c的预处理过后的文件
图2.3.1 hello.i部分内容截图
由上图中得知,在hello.i的最末尾有hello.c的源程序代码,但是hello.c只有十几行,hello.i却有3061行,说明程序的预处理阶段将一些头文件代码插入到了源程序代码前面。
图2.3.2 hello.i新增部分
可以看出,新增的部分中有在源程序中引用的stdio.h,unistd.h和stdlib.h的代码,它们都是在预处理器的作用下,被直接插入程序文本之中。他们在预处理的过程中都需要被直接插入到hello.i文件中,这会使得文件代码量与源程序相比大大增加。
另外,hello.i还引入类型定义,结构体以及外部声明:
图2.3.3 hello.i引入的定义
图2.3.4 hello.i引入的结构体
图2.3.5 hello.i引入的外部声明
2.4 本章小结
本章主要介绍了预处理的概念以及预处理在五个方面的作用与功能,并在虚拟机下将hello.c文件经过预处理生成了hello.i文件,并且对照hello.c与hello.i分析了预处理产生的文件与源文件的相同与不同之处,加深了对预处理整个过程的理解。
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译是指将以便于人类编写、阅读和维护的高级程序设计语言编写的源代码程序进行语法检查,并在语法无误的情况下,将源代码翻译成较为底层的汇编代码,形成汇编语言程序。具体来说,高级语言程序(如C语言源程序)文本文件hello.i通过编译器(如gcc)的帮助,翻译成汇编语言程序hello.s的过程称为编译。
编译的作用:
1.编译是将高级语言代码转换为汇编语言代码的过程。汇编程序中的指令更贴近机器指令,也更贴近计算机执行程序时的过程。通过编译,可以将高级语言的抽象概念映射到机器层面的具体实现,从而为后续转换成机器语言代码做准备。从文件格式来看,这个过程是将.i文件转换成.s文件。
2.编译器在编译过程中还可以进行代码优化,以提高程序的性能和效率。优化可以包括删除不必要的代码、改进代码结构、减少内存使用和提高执行速度等。
编译是将高级语言编写的源代码程序翻译成汇编语言程序的重要步骤,通过编译,源代码得以转化为更接近机器指令的汇编代码,方便后续的机器码生成。同时,编译过程还包括语法检查和程序优化,帮助程序员发现和修复代码中的错误,并提高程序的性能和效率。理解编译过程及其作用,是深入理解程序执行和优化程序性能的基础。
3.2 在Ubuntu下编译的命令
图3.2.1
3.3 Hello的编译结果解析
3.3.1 数据
- 数字常量
for循环中的i<10是由$9来实现的
图3.3.2
- 字符串常量
在hello.s文件中发现
图 3.3.3 字符串常量
如图,根据所学知识文件hello.s中将字符串常量存储在只读段.rodata段,该结果表示编译对中文字符进行了utf-8编码,并存储在只读代码区的.rodata节,在程序运行时会直接通过寻址找到常量。.align 8对齐到8字节边界,.LC0和.LC1是字符串常量的标签,分别对应于C代码中的两个字符串。
hello.c输出字符串常量的对应hello.s关系如下
图3.3.4
这部分代码用于输出错误信息"用法: Hello 学号 姓名 手机号 秒数!\n"。leaq .LC0(%rip), %rdi将字符串常量的地址加载到%rdi寄存器中,然后通过puts函数输出
图3.3.5
这部分代码用于循环中输出"Hello %s %s %s\n"。先通过一系列movq和addq指令将参数argv[1]、argv[2]和argv[3]的地址加载到寄存器中,然后通过leaq .LC1(%rip), %rdi将格式字符串的地址加载到%rdi,再通过call printf@PLT调用printf函数输出格式化字符串。
打印字符串常量时,编译器将语句翻译为先将字符串存放的地址存入寄存器%rdi,再进行打印。
- 变量
不同类型的变量在不同位置定义,局部变量在栈上进行定义和释放,未初始化的全局变量和静态变量定义在只读代码区的.bss节,已初始化的全局和静态变量定义在只读代码区的.data节。
在hello.c中,存在局部变量i,编译器进行编译的时候将局部变量i会放在栈中,如下图所示:
图3.3.6 局部变量i
由上图可知,局部变量int i存放在栈中-4(%rbp)的位置。
传入参数char *argv[]存放在栈中,如下:
图3.3.7 局部变量argv[i]
可以得出:储存参数时参数argc和argv保存在栈帧的特定位置。argc存储在-20(%rbp)位置,argv存储在-32(%rbp)位置。%edi寄存器中保存argc,%rsi寄存器中保存argv。
在循环体中,通过一系列movq和addq指令访问argv中的各个参数。以下是详细的访问过程:
首先,将argv基地址存入%rax,然后通过addq $8, %rax获取argv[1]的地址,接着通过movq (%rax), %rax将argv[1]的内容加载到%rax,最后将其移动到%rsi寄存器。
访问argv[2]与访问argv[1]类似,只是偏移量变为$16,将argv[2]的内容加载到%rdx寄存器。
对于argv[3],偏移量为$24,将argv[3]的内容加载到%rcx寄存器。
最后是argv[4],偏移量为$32,将argv[4]的内容加载到%rax寄存器,随后将其移动到%rdi寄存器,并调用atoi函数将其转换为整数。
3.3.2 赋值
编译器将赋值的操作主要为对相应的寄存器或栈进行赋值。在hello.c中,对i赋初值,根据下图,对i:movl $0, -4(%rbp)
图 3.3.8 对局部变量i赋值
局部变量赋值采取MOV指令,根据不同大小的数据类型有movb、movw、movl、movq等。
对于主要的算数操作,+转换成add,-转换成sub,*转换成imul,/转换成div。
如下图,程序中的算数操作i++在hello.s文件中的汇编代码翻译为用add指令通过操作数$1来实现,通过3.3.2的分析可以知道栈中-4(%rbp)的位置存放的是局部变量i,每次执行这条指令,实现的是i自增1。
图 3.3.9 局部变量i自加运算
3.3.4 类型转换
隐式转换:隐式转换就是系统默认的、不需要加以声明就可以进行的转换数据类型自动提升。显示转换:程序通过强制类型转换运算符将某类型数据转换为另一种类型。
hello.c程序中显示类型转换为atoi(argv[4])
图 3.3.10 类型转换
3.3.5关系操作
如下图,hello.c中有argc!=5,汇编语言为cmpl $5, -20(%rbp)。
hello.c中有i<8汇编语言为cmpl $9, -4(%rbp)。
图 3.3.11 关系操作
所以,编译器通常通过将比较编译为cmp指令实现,成立则返回0。根据不同的数据大小,有cmpb、cmpw、cmpl和cmpq。比较之后,通过jmp系列指令跳转。
3.3.6数组/指针/结构操作
如下图,编译器对源代码中数组的操作往往翻译为对地址的加减操作,其中首地址存放在栈中-32(%rbp)的位置,argv[1]地址为-32(%rbp)+$8,argv[2]地址为-32(%rbp)+$16,argv[3]地址为-32(%rbp)+$24即进行了地址的加减操作以访问数组。
图 3.3.12 数组/指针/结构操作
3.3.7控制转移
下图中第一个为条件跳转,当argc=5时才发生跳转,对应的c语言代码为if(argc!=5),第二个为无条件跳转,当i赋初值为0后跳转进入循环体。
图 3.3.13 控制转移
3.3.8函数操作
在程序中一共有6个函数调用,如下图,分别为printf(puts),exit,printf,sleep,atoi,getchar。
图 3.3.14 函数操作
从下图可以看到,在C语言源程序中执行该命令用的是printf函数,由于打印的是一个单纯的字符串,因此编译器对它进行了优化,改用puts函数进行打印,所以hello.s调用puts()函数。首先将调用函数所需要的参数,即需要打印的字符串常量的地址存放在寄存器%rdi中,然后执行call puts@PLT指令打印字符串。
对于第二个函数调用,即exit,可以看到函数的第一个参数1保存在%edi中。
图 3.3.15 函数操作
如下图,对于第三个函数调用printf,%rdi中存放第一个参数,%rsi中存放第二个参数。
图 3.3.16 函数操作
如下图,对于第四个和第五个函数调用,参数都存放在%rdi中,其中sleep的参数为atoi的返回值。
图 3.3.17 函数操作
如下图,关于第六个函数调用,由于getchar无参数输入,所以不用寄存器传参。
图 3.3.18 函数操作
3.4 本章小结
本章主要介绍了编译的概念和功能,包括如何将高级语言指令翻译为汇编语言指令,并对编译器优化的过程进行了详细解释。通过在虚拟机环境中操作,将hello.i文件编译生成了hello.s文件,展示了实际的编译过程。
随后,依据C语言的不同数据类型和操作类型,详细分析了源程序hello.c文件中的语句是如何转化为hello.s文件中的汇编语句。数据类型包括数值常量、字符串常量和局部变量;操作类型涵盖了赋值、算术运算、关系运算、类型转换、数组操作、指针操作、结构操作、控制转移以及函数调用等。
通过这些内容的学习,我对汇编代码与C语言的关系有了更深刻的理解,显著提高了阅读和理解汇编代码的能力。
4.1 汇编的概念与作用
汇编是指将汇编语言程序通过汇编器(如as)转换为二进制的机器语言指令,并将这些指令打包成可重定位目标程序的格式,保存在目标文件(.o)中。具体来说,就是将文本文件hello.s通过汇编器(as)转换为可重定位目标程序(二进制)的hello.o文件的过程。汇编语言是一种低级计算机语言,使用助记符来表示机器指令,可以被计算机直接执行。作为计算机硬件和软件之间的桥梁,汇编语言将高级语言编写的程序转换成机器语言,使计算机能够理解和执行这些指令。
汇编的作用:
1.将汇编语言翻译成机器语言: 汇编的主要作用是将汇编语言转换为机器语言。汇编器通过将汇编语言中的助记符翻译成二进制码(0和1),生成由机器指令构成的程序。这些指令可以被计算机直接识别和执行。翻译成相应的机器指令,并打包成目标文件。
2.生成可重定位目标程序: 汇编器不仅将汇编语言翻译成机器语言,还将这些指令打包成可重定位目标程序,并保存在目标文件(.o)中。可重定位目标程序是一种中间文件,它包含了机器指令和其他相关信息,可以在链接阶段进行进一步处理。例如,将hello.s转换成hello.o文件,生成的hello.o文件可以被链接器用来创建可执行文件。
3.为链接打基础: 汇编为之后的链接阶段打下基础。通过将汇编代码翻译成机器指令并生成目标文件,汇编器为链接器提供了必要的输入文件。在链接阶段,链接器将多个目标文件(如hello.o)合并,解决符号引用,并生成最终的可执行文件。这一过程使得程序的模块化开发成为可能,方便了代码的管理和复用。
4.2 在Ubuntu下汇编的命令
图 4.2.1 汇编的命令
4.3 可重定位目标elf格式
readelf -a hello.o >hello.o.elf
图 4.3.1 查看ELF格式
4.3.1 ELF头
hello.o的ELF格式以一个16字节(一共40字节)的Magic序列开始,描述了生成该文件的系统的字大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包含ELF头的大小,程序头部表的大小,目标文件的类型,机器类型,节头部表的文件偏移,以及节头部表中条目的大小和数量。
图 4.3.2 ELF头
4.3.2 节头目表
ELF文件格式中的节头部表描述了目标文件中不同节的类型、地址、大小、偏移等信息,以及可以对各部分进行的操作权限。
hello.o文件的节头目表如下图:
图4.3.3 节头目表
4.3.3 重定位节
ELF文件格式中的重定位节包含两个部分:.rela.text节与.rela.eh_frame节。
图 4.3.4 重定位节
重定位节分成两部分:节头部分和重定位表部分。节头部分包括:节名、节大小、节偏移量等关键信息。重定位表部分包括了需要进行重定位的地址和符号信息,以及重定位的类型和偏移量等。
.rela.text是一个.text节中位置的列表,包含.text节中需要进行重定位的信息。由上图可知,在hello.o的重定位节中包含了main函数调用的puts、exit、printf、sleep、getchar函数以及全局变量,还有只读区域.rodata节存的两个字符串常量。表格记录了它们的偏移量、信息、类型、符号值、符号名称及加数。另用rela.eh_frame单独记录.text的信息。
表头信息含义为:
偏移量:需要重定位的信息的字节偏移位置(代码节/数据节)
信息:重定位目标在.symtab中的偏移量和重定位类型
类型:表示不同的重定位类型
符号值:符号的数值
符号名称:被重定位时指向的符号
加数:偏移
若重定义类型为R_X86_64_PC32,重定位一个使用32位PC相对地址的引用。若若重定义类型为R_X86_64_32,重定位一个使用32位绝对地址的引用。根据重定位条目和重定位算法即可得到相应的重定位位置。
以.rela.text第一行为例:
- 偏移量:0x1c
- 信息:0x000500000002
- 类型:R_X86_64_PC32
- 符号值:0x0000000000000000
- 符号名称 + 加数:.rodata - 4
- 这个条目表示在.text段的偏移量0x1c处有一个需要进行PC相对地址重定位的地方,目标符号是.rodata,并且有一个-4的加数
4.3.4 符号表
ELF文件格式中的符号表用于存放程序中定义和引用的函数和全局变量的信息。
图 4.3.5 符号表
4.4 Hello.o的结果解析
objdump -d -r hello.o > hello.o.asm
图 4.4.1 hello.o .asm反汇编文件
通过对比hello.s和hello.o .asm发现不同之处:
4.4.1 包含内容
hello.s中包含.type ,.rodata,.file,.section ,.align(对齐) 等信息,反汇编文件hello.o .asm中只有代码段.text的相关内容。
图 4.4.2 包含内容区别
4.4.2 数字进制
如下图,在汇编过程中,数字的进制发生了改变,从十进制转换为了十六进制,对应机器的二进制数。
图4.4.3 数字进制区别
4.4.3 引用地址区别
例如,在汇编过程中对于字符串常量的引用时,基准地址不同:
图 4.4.4 字符串常量引用基准地址不同
hello.s中是用的内部代码全局变量所在.LC0段的名称加上%rip的值,而重定位目标文件(汇编后)hello.o中用的是0加%rip的值。
4.4.3 分支转移
图 4.4.5 分支转移地址的区别
hello.s通过段名进行分支转移,跳转指令后用对应的段的名称表示跳转位置,而在hello.o的反汇编代码中每个段都有明确的地址,跳转指令后用相应的绝对或者相对地址表示跳转位置。
4.4.4 函数调用
图 4.4.6 函数调用的区别
可以看到,hello.s中直接调用函数的名称,而hello.o .asm中利用下一条地址相对函数起始地址的偏移量(R_X86_64_PC32),链接重定位后才能确定地址。
4.5 本章小结
本章详细介绍了汇编语言的概念及其作用,重点说明了如何使用汇编器(as)将汇编语言程序转换为二进制机器语言指令,并将这些指令打包成可重定位目标文件格式,保存为目标文件hello.o。通过readelf工具,对hello.o文件的ELF格式进行了分析,列出了各节的基本信息,包括ELF头、节头表、重定位节和符号表的功能及其包含的信息。
接着,利用objdump工具对hello.o文件进行了反汇编,生成了hello.o.asm文本文件,并分析了反汇编代码与原始汇编语言程序hello.s中各语句之间的对应关系。分析内容包括数字进制、字符串常量的引用、分支跳转和函数调用等四个方面的对应关系。
第5章 链接
5.1 链接的概念与作用
链接的概念:
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存中并执行。链接可以发生在不同的阶段:
- 编译时(compile time):在源代码被翻译成机器代码时进行链接。
- 加载时(load time):在程序被加载器(loader)加载到内存并执行时进行链接。
- 运行时(runtime):由应用程序在运行过程中执行链接。
在早期计算机系统中,链接是手动完成的。在现代系统中,链接过程由称为链接器(linker)的程序自动执行。
链接的作用:
链接在程序开发和执行过程中起着至关重要的作用,具体表现如下:
- 构造大型程序:
链接使得分离编译(separate compilation)成为可能。通过将不同模块的代码分别编译成可重定位目标文件(例如hello.o),然后链接成一个单一的可执行文件,开发者可以独立编译和测试各个模块,提高开发效率和代码复用性。
- 避免编程错误:
链接有助于避免某些危险的编程错误。例如,如果一个程序错误地定义了多个全局变量,链接器在默认情况下不会发出警告,但这可能导致程序在运行时表现出令人迷惑的行为并难以调试。通过理解链接过程,可以分析这些错误是如何发生的,以及如何避免它们。
- 实现语言的作用域规则:
链接帮助理解语言的作用域规则。例如,全局变量和局部变量之间的区别,一个具有static属性的变量或函数的实际意义等。通过链接器,可以看到这些作用域规则是如何在最终的可执行文件中实现的。
- 理解系统概念:
链接器生成的可执行文件在许多重要的系统功能中扮演关键角色,如加载和运行程序、虚拟内存、分页、内存映射等。通过研究链接过程,可以更好地理解这些系统概念及其实现方式。
- 利用共享库:
链接使得使用共享库成为可能。许多软件产品在运行时使用共享库来升级压缩包装的二进制程序(shrink-wrapped binary programs)。此外,大多数Web服务器依赖共享库的动态链接来提供动态内容。这种机制不仅减少了存储空间,还提高了程序的可维护性和更新效率。
链接过程将分散的代码和数据片段整合成一个可执行的整体,为程序的开发、调试、执行和维护提供了有力支持。通过掌握链接的原理和技术,开发者能够更好地构建和管理复杂的软件系统。
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
虚拟机上链接过程如下:
图 5.2.1 链接的命令
链接生成的可执行文件hello如图所示:
5.3 可执行目标文件hello的格式
通过指令readelf -a hello>hello.elf查看目标文件hello的ELF格式。
图5.3.1 生成.elf文件
图5.3.2 ELF header
以一个16字节的Magic序列开始,描述了生成该文件的系统的字大小和字节 顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包 含ELF头的大小,程序头部表的大小,目标文件的类型,机器类型,节头部表的文件偏移,以及节头部表中条目的大小和数量。与链接前的ELF Header 比较,number of section headers,number of program headers 增加,入口地址不再是0,说明重定位工作已完成。
图5.3.3 节头表
节头表对hello中所有的节的信息进行了声明,包括大小,偏移量等。hello中的节头表条目数多于hello.o。hello中每一节都有实际地址,而hello.o中每一节地址值都为0,说明重定位工作已完成。
图5.3.4 程序头部表
根据上图可知,程序头包含了以下信息:
PHDR:程序头表
INTERP:程序执行前需要调用的解释器
LOAD:程序目标代码和常量信息
DYNAMIC:动态链接器所使用的信息
NOTE::辅助信息
GNU_STACK:使用系统栈所需要的权限信息
GNU_RELRO:保存在重定位之后只读信息的位置
图5.3.5 段头表
由图,可执行文件内容初始化为两个内存段:只读内存段(代码段.text),读写数据段(.data)
图5.3.6 符号表
目标文件的符号表包含定位和重定位程序的符号定义和符号引用所需的信息
图5.3.7 重定位节
重定位后的偏移量与未重定位的hello.o的明显不同。
5.4 hello的虚拟地址空间
根据5.3.2节头表,可知ELF表头地址为0x400000
edb通过Data Dump验证:
图 5.4.1 ELF表头虚拟地址的对应
根据5.3.2节头表,可知程序的入口地址为0x4010f0,对应ELF中.text节的起始地址edb通过Data Dump验证:
图 5.4.2 ELF.text节的起始虚拟地址的对应
图 5.4.3 ELF.interp节的起始虚拟地址的对应
根据5.3.2节头表,可知程序的入口地址为0x4002e0,对应ELF中.interp节的起始地址
由edb通过Data Dump与stack查看存储的内容可知:/lib64/ld-linux-x86-64.so.2为动态连接器(dynamic linker)的路径。
根据图5.3.4 程序头部表知.rodata节的起始虚拟地址为0x402000
图 5.4.4 ELF.data节的起始虚拟地址的对应
根据图5.3.4 程序头部表知.data节的起始虚拟地址为0x404030
5.5 链接的重定位过程分析
通过指令objdump -d -r hello > hello.asm将可执行文件hello反汇编到文本文件hello.asm中。
图 5.5.1 反汇编指令与生成的hello反汇编文件hello.asm
5.5.1 代码体量
从图中可以看出,可执行文件反汇编生成的代码量远大于可重定位目标文件反汇编生成的代码量。
图 5.5.2 体量
5.5.2 起始地址
hello.o反汇编代码地址从0开始,hello反汇编代码地址从 0x400000开始,说明hello.0未实现重定位,每个符号没有确定的地址,hello已实现,每个符号有确定的地址
图 5.5.3 hello反汇编代码起始地址
图 5.5.4 hello.o反汇编代码起始地址
5.5.3 函数
在hello.o的反汇编程序中,只有main函数,没有调用的函数段;经过链接过程后,原来调用的C标准库中的代码都被插入了代码中,并且每个函数都被分配了各自的虚拟地址。
图 5.5.4 部分hello调用的C标准库函数
另外,对比两个反汇编文件,可以看出,在hello.o的反汇编程序中,由于当时函数未被分配地址,所以调用函数的位置都用call加下一条指令地址来表示,而在hello的反汇编程序中,由于各函数已拥有了各自的虚拟地址,所以在call后加其虚拟地址来实现函数调用。
图 5.5.5 hello调用printf函数
图 5.5.6 hello.o调用下一条指令
5.5.4 CPU分配虚拟地址
从图中可以看出在hello.o的反汇编程序中, main函数中的所有语句前面的地址都是从main函数开始从0开始依次递增的,而经过链接后,每一条语句都被分配了虚拟地址。
图 5.5.7 hello与hello.o代码地址
同样,对比两个反汇编文件的跳转指令,在hello.o的反汇编程序中,对于跳转指令,在其后加上目的地址,为main从0开始对每条指令分配的地址,而在hello的反汇编程序中,由于各语句拥有了各自的虚拟地址,所以同样加上目的地址,但这里是每条指令的虚拟地址。
图 5.5.8 hello跳转指令
图 5.5.8 hello.o跳转指令
5.5.5 字符常量的引用
从图中可以看到,在hello.o的反汇编程序中,字符串常量的位置是用0加%rip的值来表示的,这是由于当时字符串常量并未分配虚拟内存,而在hello的反汇编程序中,因为字符串常量都有了相应的位置,所以用实际的相对下一条语句的相对地址加%rip即PC的值来描述其位置。
图 5.5.9 hello.o引用字符常量
图 5.5.9hello引用字符常量
5.5.6 总结
链接的过程主要分为两个步骤:
符号解析:
符号解析是链接器解析目标文件中的符号定义和引用,并将每个符号引用与相应的符号定义关联起来的过程。在这一过程中,链接器会检查每个目标文件,确保所有引用的符号都有对应的定义。
重定位:
编译器和汇编器生成的代码和数据节通常从地址0开始。链接器通过将每个符号定义与一个虚拟内存地址相关联,将这些代码和数据节重定位到适当的内存地址。链接器会修改所有符号引用,使其指向这些新的虚拟内存地址。具体来说,链接器会计算每个目标文件中代码和数据节的实际加载地址,并更新代码中的符号引用,使其与新的内存地址相匹配。
链接器确保了程序中的每条指令和每个符号都对应到正确的虚拟地址。在程序执行过程中,函数调用、全局变量引用以及跳转操作等都通过这些虚拟地址进行,从而保证程序的正确执行。从对比图可以看出,在hello程序中,每条指令都关联到了一个虚拟地址,同时每个函数和全局变量也被关联到相应的虚拟地址。在函数调用、全局变量引用以及跳转等操作时,程序通过虚拟地址来访问这些指令和数据,从而实现程序的正确运行。
5.6 hello的执行流程
图5.6.1 调用_dl_start
- Step over运行后下一个调用的程序为_dl_init,地址:0x7fe3a3dfec20
图5.6.2 调用_dl_init
- Step over运行后程序通过jmp 进入,地址为0x4010f0,则程序进入函数入口。
图5.6.3 jmp 进入函数入口
- 继续Step over,看到_start函数中通过调用_libc_start_main函数,地址:
0x7fe3a3c06f90
图5.6.4 调用_libc_start_main
- 进入_libc_start_main函数后继续Step over,看到_libc_start_main函数中调用_cxa_atexit函数,地址:0x7fe3a3c29de0。
图5.6.5 调用_cxa_atexit
6. 继续Step over,看到_libc_start_main函数中通过%rcx跳转至hello_init函数,地址:0x4011d0
图5.6.6 调用hello_init
图5.6.7 进入hello_init
7. step out后继续Step over看到_libc_start_main函数中通过%rbx跳转至setjump函数非本地跳转,地址0x7f2460350c80
图5.6.8 调用setjump
图5.6.9 进入setjump
- 继续运行,可以看到程序进入main函数,地址:0x401125
图5.6.10 进入main
- 继续Step over,进入hello.plt可看到main函数调用puts@plt函数(优化),地址:0x401030
图5.6.11 调用puts@plt函数
然后调用0x401060处的atoi@plt函数,接着调用0x401080处的sleep@plt函数,循环10次后调用位于0x401050处的getchar@plt函数.
图5.6.12 调用exit@plt函数
- 最后调用exit@plt函数退出,地址:0x401070
在exit@plt函数中调用libc-2.31.so!exit函数退出程序
图5.6.13 调用libc.so.6!exit函数退出程序
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时,还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
图5.7.1 hello.elf中的节头表.got.plt地址
.got.plt数据从0x40400开始
调用_dlt_init之前GOT表内容:
图5.7.2 调用_dlt_init之前GOT表
调用_dlt_init之后GOT表
图5.7.3 调用_dlt_init之后GOT表
通过两幅图的比较可以看出调用dlinit后GOT表后字节数据发生了变化,经过初始化后,PLT和GOT表可以协调工作,一同延迟解析库函数的地址。
5.8 本章小结
本章介绍了链接的概念和作用,在linux系统中通过命令进行链接,详细说明 了可执行目标文件hello的格式。分析了可执行自标文件和可重定位目标文件的区 别。详细分析了hello虚拟地址空间,与hello执行过程,分析了程序如何实现动态链接。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:
进程是一个执行中程序的实例,是操作系统对一个正在运行的程序的抽象。它是计算机中的程序在特定数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,而进程是程序的实体。
进程的作用:
- 并发执行:进程允许多个任务在同一时刻执行,从而实现了并发性。多个进程可以同时运行,使得计算机系统能够高效地处理多个任务。
- 资源分配:操作系统通过进程的创建和管理,为每个进程分配独立的内存空间、文件和其他系统资源。这样,各个进程之间不会相互干扰,提高了系统的稳定性和安全性。
- 隔离性:进程之间是相互隔离的,一个进程的错误或崩溃通常不会影响其他进程的正常运行。这种隔离性提高了系统的可靠性。
- 通信和同步:进程之间可以通过进程间通信(Inter-Process Communication,IPC)机制进行数据交换和共享。同时,进程间可以通过同步机制协调各自的执行顺序,防止竞态条件和死锁等问题的发生。
- 灵活性:进程可以独立执行和退出,使得计算机系统更加灵活。新的进程可以被创建,而不需要修改已有的进程,从而方便系统的扩展和维护。
- 虚拟存储:操作系统通过虚拟存储技术为每个进程提供了独立的地址空间,使得每个进程都能够拥有自己的内存空间,而不受其他进程的影响。
- 多用户支持:操作系统通过进程的管理,可以同时支持多个用户使用计算机,每个用户的任务都是一个独立的进程。
6.2 简述壳Shell-bash的作用与处理流程
hell俗称壳,是一种指“为使用者提供操作界面”的嵌入式软件。软件提供了一种允许用户与其他操作系统之间进行通讯的一种方式。这种简单的通讯方式可以以交互方式,或者以非交互的方式允许用户执行。shell是一个简单的命令解释器,它允许系统接收到一个用户的命令,然后自动调用相应的命令执行应用程序。
Shell的处理流程:shell读取用户从终端使用外部设备输入的指令。解析所读取的指令,如果这个指令是一个内部指令则立即执行,否则,加载调用一个应用程序为申请的程序创建新的子进程,在子进程的上下文中运行。同时shell还允许接收从键盘读入的外部信号,并根据不同信号的功能进行对应的处理。
6.3 Hello的fork进程创建过程![](https://img-blog.csdnimg.cn/direct/8c242c64444a4d7d8d969a593bcaed5a.png)
图6.3.1
在一个 shell 程序中,当输入的指令不是内置指令时,会调用 fork 函数创建一个子进程。子进程会获得父进程虚拟地址空间的一份数据结构副本。
调用 fork() 函数创建子进程时,fork() 函数最终调用 fork 系统调用,并执行系统调用处理函数。在此过程中,存放系统调用号的寄存器(EAX)中存储常数 SYS_fork(在 IA-32 + Linux 平台中为 2)。随后,调用相应的系统调用服务例程 sys_fork() 来执行创建子进程的任务。
sys_fork() 最终会创建一个子进程,该子进程的存储器映射与父进程完全相同。具体来说,子进程会完全复制父进程的 mm_struct、vm_area_struct 数据结构和页表,并将这些结构中的每一个私有页的访问权限都设置为只读。最终,将两者的 vm_area_struct 中描述的私有区域中的页都设置为私有的写时拷贝页(Copy-On-Write 页)。
假设父进程先被调度返回执行,则在子进程结束后,父进程将返回到 fork() 函数的调用点
6.4 Hello的execve过程
在创建子进程后,系统会加载并执行 hello 程序。子进程通过调用 execve() 系统调用封装函数,在函数参数中指定要加载和执行的 hello 程序。execve() 函数最终调用 execve 系统调用,执行系统调用处理函数。此时,存放系统调用号的寄存器(EAX)中存储常数 SYS_execve(在 IA-32 + Linux 平台中为 11)。
操作系统的加载器负责将 hello 程序加载到当前进程的虚拟地址空间。加载器会删除子进程现有的虚拟内存段,并创建一组新的代码段、数据段、堆段和栈段。新的栈和堆段会被初始化为零,并为用户栈分配页框并映射到当前进程的虚拟地址空间。同时,参数 argv 和环境变量 envp 被放入栈中。
加载器会遍历 hello 可执行文件(ELF 格式)的程序头表,找到动态加载器的路径。然后,再次遍历 hello 的程序头表,获取可加载段的信息,并通过 do_mmap() 内核函数将动态加载器文件(ELF 格式)中的可加载段映射到当前进程的虚拟地址空间中。在此过程中,加载器会填写相应的 vm_area_struct 数据结构,记录可加载段对应的区域和属性标志。新的代码段和数据段被初始化为可执行文件中的内容。
加载器将动态加载器的入口地址设置为当前进程执行的入口。在加载并执行了一系列动态加载器的指令后,动态加载器会进行加载时的动态链接。最终,加载器设置 PC(程序计数器)指向 _start 地址,_start 最终调用 hello 程序中的 main 函数。execve 函数负责加载并运行可执行目标文件 filename,并带有参数列表 argv 和环境变量列表 envp。只有在出现错误时,例如找不到 filename,execve 才会返回到调用程序。
图 6.4.1 用户栈的典型结构
6.5 Hello的进程执行
上下文信息:上下文是内核重新启动一个被抢占的进程所需的状态,由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
进程时间片:进程执行其控制流的一部分的每个时间段称为时间片(time slice)。因此,多任务处理也称为时间分片(timeslicing)。
进程调度过程:
当系统开始运行 hello 程序时,会为 hello 进程分配一个时间片。在这个时间片内,处理器将执行 hello 进程的指令。在系统中存在多个进程的情况下,处理器的一个物理控制流被分成多个逻辑控制流并发执行。在用户态下执行 hello 进程时,系统会保存当前的上下文信息,包括寄存器的状态和程序计数器等。
用户态与核心态转换:
如果在 hello 进程执行期间发生异常或系统中断,例如 TLB 缺页故障,系统会触发用户态到核心态的转换。内核会阻塞当前的 hello 进程,并在核心态中进行上下文切换。通过调用 schedule() 函数,调度器选择系统中的另一个用户进程 Q,保存 hello 进程的上下文信息,然后加载进程 Q 的上下文信息并恢复其执行。
当 hello 程序执行到 sleep 函数时,hello 进程会进入休眠状态,触发一次上下文切换,控制权交给其他进程。过了一段时间后,系统会再次进行上下文切换,恢复 hello 进程在休眠前的上下文信息,控制权重新回到 hello 进程,继续执行其后续指令。
在 hello 程序的循环结束后,调用 getchar() 函数时,hello 进程会从用户模式进入内核模式,再次进行上下文切换,控制权交给其他进程。最终,内核从其他进程切换回 hello 进程,恢复其上下文信息,在执行完 return 后,hello 进程结束,其上下文信息被释放。
图6.5.1进程的上下文切换
6.6 hello的异常与信号处理
(hello执行过程中会产生的异常:
- 命令行参数异常:如果命令行参数不是5个,程序会输出错误信息并退出(陷阱异常)
图6.6.1命令行参数异常
2. 系统调用异常:当程序使用 sleep 函数时,如果调用失败,会触发 SIGALRM 信号,这会导致程序退出,从而终止异常。
3. 用户输入异常:如果用户在程序运行过程中按下 Ctrl-C,会触发 SIGINT 信号,导致程序退出。按下 Ctrl-Z 会挂起程序,此时可以使用 fg 命令将程序恢复到前台运行,或者使用 kill 命令终止程序,这被称为中断。
在程序中,对于 SIGINT 和 SIGALRM 信号,程序采用默认的信号处理方式,即直接退出程序。对于 Ctrl-Z 产生的 SIGTSTP 信号,程序没有显式处理,而是被挂起,可以使用 fg 命令将其恢复到前台运行。
正常运行时:
图 6.6.2 正常运行
循环输出10次。
如果不停乱按(不包括回车)
在程序执行过程中不断乱按,会将乱按的字符打印在屏幕上,对程序没有其它影响,程序依然循环输出10次。循环结束后让getchar()读入回车结束运行。
如果乱按包括回车:
图 6.6.3 不停乱按(包括回车)
getchar()只会读走第一个回车,后面的字符就会先存在输入缓冲区中,程序运行结束后再输出。
如果按了ctrl+z:
图 6.6.4 Ctrl+Z
程序运行过程中键入Ctrl+Z会让程序产生中断,向前台程序发送信号SIGSTP,这时hello会接收到信号SIGSTP并运行信号处理程序让hello进程暂时停止,并打印相关信息。
在shell中输入ps命令,结果如下:
在其中可以找到被挂起的hello程序(先运行程序按ctrl+z):
图 6.6.5 Ctrl+Z后ps
输入jobs命令:
图 6.6.6 Ctrl+Z后jobs
打印出被挂起的hello的相关信息。
输入pstree
图 6.6.7 Ctrl+Z后pstree
找到hello进程:
图 6.6.8 Ctrl+Z后pstree
输入fg命令:
图 6.6.9 Ctrl+Z后fg
打印剩下的内容,接着getchar()读入回车结束进程。
按ctrl+c:
图 6.6.10 Ctrl+C
触发 SIGINT 信号,导致程序退出。
在程序运行过程中键入Ctrl+C会让程序产生中断异常,向前台程序发送信号SIGINT。
图 6.6.11 Ctrl+C后ps
hello进程在SIGINT信号的作用下强行被终止,并被父进程shell回收了。与键入Ctrl+Z效果不同。
shell中输入kill命令,结果如下。
运行hello程序,先键入ctrl+z停止程序
图 6.6.12 Ctrl+Z 后ps,kill
在shell中输入ps查看进程hello的PID接着输入kill -9 3853来发送信号SIGKILL给进程3853,从而杀死该进程。接着再输入ps查看当前进程,发现hello进程已被杀死。
6.7本章小结
(本章中阐述了进程的概念以及他在计算机中具体是如何在使用的。其次,还介绍了如何利用shell这个平台来对进程进行监理调用或发送信号等一系列操作。在异常与信号处理的分析过程中,我们使用ps、jobs、pstree、fg、kill等命令,测试了进程执行过程中按键盘出现的异常和产生的信号,深入理解了信号与异常的过程。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址是程序生成的地址,由 CPU 产生,在指令和程序中使用。它由段选择子和段内偏移量组成,在 hello 程序中,变量和指令的地址都是逻辑地址。
线性地址:线性地址是将逻辑地址通过段转换后得到的地址。现代操作系统通常使用平坦内存模型,其中段选择子基本为零,逻辑地址与线性地址相同。在 hello 程序中,逻辑地址在被段机制转换后得到线性地址。
虚拟地址:虚拟地址是在线性地址的基础上,通过页表映射到物理内存的地址。操作系统将进程的线性地址空间映射到实际的物理内存位置。在 hello 程序中,每个进程都有自己的虚拟地址空间,保证了进程间的隔离和安全性。
物理地址:物理地址是内存单元在实际物理内存中的地址,是最终用来访问内存的地址。在 hello 程序中,虚拟地址通过内存管理单元(MMU)转换为物理地址,进行内存访问。
结合 hello 程序
在 hello 程序运行时:
逻辑地址:程序中的变量和指令地址,如 argv 数组和 i 变量,其地址在程序中是逻辑地址。
线性地址:当 CPU 执行指令时,逻辑地址通过段机制(如果有)转换为线性地址。现代操作系统中,逻辑地址和线性地址通常相同。
虚拟地址:操作系统将 hello 程序的线性地址映射到虚拟地址空间中,每个进程都有独立的虚拟地址空间,例如,hello 程序中的数据段、代码段、堆栈段都有各自的虚拟地址范围。
物理地址:虚拟地址通过 MMU 转换为物理地址,实际访问物理内存。操作系统会将 hello 程序的虚拟地址映射到不同的物理内存位置,保障程序的正常运行和内存的有效使用。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由两部分组成:段标识符、段内偏移量。段标识符是由一个16位长 的字段组成,称为段选择符,段选择符用16位段寄存器存放。![](https://img-blog.csdnimg.cn/direct/eb70f0cffce84002abf39276632e0b35.png)
图 7.2.1 段选择符
段选择符各字段含义:
图 7.2.2 Intel处理器的逻辑地址向线性地址寻址
索引:高13位-8KB个索引用来确定当前使用的段描述符在描述符表中的位置,
描述符表实际上就是段表,由段描述符(段表项)组成。有三种类型:
1)全局描述符表GDT:只有一个,用来存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及TSS(任务状态段)等都属于GDT中描述的段
2)局部描述符表LDT:存放某任务(即用户进程)专用的描述符
3)中断描述符表IDT:包含256个中断门、陷阱门和任务门描述符
段描述符是一种数据结构,实际上就是段表项,分两类:
- 用户级:代码段和数据段描述符
- 系统控制段描述符
- 特殊系统控制段描述符,包括:局部描述符表(LDT)描述符和任务状态段(TSS)描述符
- 控制转移类描述符,包括:调用门描述符、任务门描述符、中断门描述符和陷阱门描述符
TI:TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)
RPL:段的级别。 RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级。Linux仅用第0和第3环。
(CS寄存器中的RPL字段表示CPU的当前特权级(Current Privilege Level,CPL)
段基址:表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。
具体转化步骤:
- 给定一个完整的逻辑地址,其形式为[段选择符:段内偏移地址]。
- 检查段选择符 T1,以确定要转换的是全局描述符表(GDT)中的段还是局部描述符表(LDT)中的段,通过相应的寄存器获取表的地址和大小。
- 从段选择符中提取出13位的索引值,并在描述符表中查找相应的段描述符,得到基地址(BASE)。
- 计算线性地址,线性地址等于基地址加段内偏移地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址到物理地址的转换是通过页式管理机制实现的。该机制通过分页来管理虚拟内存空间。CPU利用页表,通过内存管理单元(MMU)完成从虚拟地址到物理地址的转换。页表是一个页表条目(PTE)数组,存储在内存中,用于将虚拟页地址映射到物理页地址。一个虚拟地址包含两个部分:虚拟页面偏移(VPO)和虚拟页号(VPN),其中VPO与物理页面偏移(PPO)相同。MMU利用VPN选择适当的PTE,完成虚拟地址到物理地址的转换。如果转换成功,便得到物理地址;如果转换失败,会触发缺页故障,从而调用缺页处理程序进行处理。
在程序中,变量和指令都存储在内存中,每个变量和指令都有一个虚拟地址。当程序访问一个变量或指令时,操作系统将虚拟地址分解为页表项索引和页内偏移量。根据页表项索引找到页表,并根据页内偏移量计算出物理地址。程序实际访问的是物理地址对应的内存空间。
图 7.3.1 线性地址到物理地址的变换
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页 表条目)将虚拟地址翻译为物理地址。这会造成大量的时间开销,为了消除开销, MMU 中设计了一个关于PTE的小的缓存,Translation Lookaside Buffer (TLB)翻译后备缓冲器,俗称快表,是MMU中的一个小的具有高相联度的集合,实现虚拟页码向物理页码的映射。
每当CPU产生一个虚拟地址时,MMU(内存管理单元)需要查找一个页表条目(PTE)以将虚拟地址转换为物理地址。这一过程会带来显著的时间开销。为了减少这种开销,MMU中设计了一种关于PTE的小型高速缓存,称为TLB(Translation Lookaside Buffer,翻译后备缓冲器),俗称快表。TLB是MMU中的一个小型高相联度缓存,用于加速虚拟页码到物理页码的映射。
具体转换步骤如下:
TLB查找:当CPU产生一个虚拟地址时,首先检查TLB。TLB存储了最近使用的PTE。如果TLB命中(即在TLB中找到对应的PTE),则直接使用TLB中的PTE进行地址转换,得到物理地址。
TLB未命中:如果TLB未命中,则需要通过四级页表进行地址转换。虚拟地址被分为五个部分:页目录指针(PDP)、页目录(PD)、页表(PT)、页内偏移(Offset),以及每级页表的索引。
第一级页表(PML4):使用虚拟地址的最高位部分作为索引,从PML4中找到指向第二级页表的指针。
第二级页表(PDP):使用PML4中的指针和虚拟地址的下一部分作为索引,从PDP中找到指向第三级页表的指针。
第三级页表(PD):使用PDP中的指针和虚拟地址的下一部分作为索引,从PD中找到指向第四级页表的指针。
第四级页表(PT):使用PD中的指针和虚拟地址的下一部分作为索引,从PT中找到指向物理页的指针。
页内偏移:最后,使用虚拟地址的页内偏移部分,加上物理页基地址,得到最终的物理地址。
更新TLB:将新的PTE缓存到TLB中,以便加速后续相同虚拟地址的转换过程。
这样,TLB显著提高了虚拟地址到物理地址转换的效率,减少了由于页表查找带来的性能开销。
7.5 三级Cache支持下的物理内存访问
高速缓存通常采用一种折中的方案,即组相联高速缓存结构。组相联高速缓存中,每个组内可以包含多个缓存行。其总体逻辑类似于直接映射高速缓存,但在行匹配时,每组中有更多的行可供尝试匹配,通过遍历每一行进行匹配。如果未命中且有空行(即冷不命中),则直接将数据存储在空行中;如果没有空行(即冲突不命中),则需要替换已有行,通常采用LFU(最不常使用)或LRU(最近最少使用)两种替换策略。
在三级Cache支持下,物理内存访问的过程如下:
1.TLB查找:CPU发出一个虚拟地址,首先在TLB中搜索。如果命中,直接发送到L1缓存;如果未命中,则加载到TLB后再发送过去。
2.Cache查找:
- 一级Cache(L1):使用物理地址的组索引部分查找对应的组。在组内比较标签(tag)位,如果匹配且有效(valid)位为1,则L1缓存命中,取出值并传给CPU。
- 二级Cache(L2)和三级Cache(L3):如果L1缓存未命中,则依次对L2和L3缓存执行相同的查找过程。每次查找都包括比较标签位和检查有效位,直到某一级缓存命中。
3.内存查找:如果所有三级缓存都未命中,则访问主存取出数据。
4.写回操作:一旦数据在某级缓存或内存中找到,需将数据逐级写回到上一级缓存。如果缓存中有空位,则直接写回;否则,采用替换算法(如LFU或LRU)将已有数据驱逐后再写回。
组相联高速缓存结合了直接映射和全相联缓存的优点,提高了缓存命中率和访问效率。
图 7.5.1 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射![](https://img-blog.csdnimg.cn/direct/4aecc6e54e514b80a4305359255d9b79.png)
图7.6.1 fork创建独立虚拟空间新进程
当 shell 调用 fork 函数时,内核会为 hello 进程创建各种数据结构,并分配一个唯一的 PID 以标识该进程。为了创建 hello 进程的虚拟内存,内核会复制 hello 进程的 mm_struct(内存描述符)、区域结构(区域描述符)和页表。这些数据结构的副本使得 hello 进程拥有独立的逻辑控制流,并且能够访问当前已打开的文件和页表中的数据。
内核将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制(COW)。当 fork 在 hello 进程中返回时,hello 进程的虚拟内存与调用 fork 时的虚拟内存完全相同。
如果任一进程随后尝试写入一个页面,写时复制机制将触发,创建一个新页面来保存修改数据。这确保了每个进程都保持自己的私有地址空间,从而维护了进程间的内存隔离和独立性。
7.7 hello进程execve时的内存映射![](https://img-blog.csdnimg.cn/direct/e0e17fe9429d4fe1a2b8e5b11e18dca4.png)
当 hello 进程执行 execve 函数时,会加载并运行包含在可执行文件 hello 中的程序:
1.删除已存在的用户区域:删除 shell 进程虚拟地址空间中用户部分的已存在的区域结构。
2.映射私有区域:为 hello 程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的,且为写时复制(COW)。具体细节如下:
- 代码区域和数据区域分别映射为 hello 文件中的 .text 和 .data 区域。
- bss 区域是未初始化数据段,映射到匿名文件,其大小在 hello 文件中指定。
- 栈和堆区域也映射到匿名文件,初始长度为零。
3.映射共享区域:如果 hello 程序与共享对象(如标准 C 库 libc.so)链接,则这些共享对象会被动态链接到程序中,并映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC):execve 的最后一步是设置当前进程上下文中的程序计数器,将其指向动态加载器的入口地址,从而使当前进程从代码区域的入口点开始执行。
经过上述内存映射过程,在下一次调度到 hello 进程时,系统就能够从 hello 程序的入口点开始执行。
7.8 缺页故障与缺页中断处理
当CPU执行某条指令的内存访问时,如果页表中的PTE表明这个地址对应的页不在物理内存中,那么就会引发缺页故障,这使得跳转入内核态,执行操作系统提供的缺页中断处理程序,然后缺页中断处理程序能够将存在磁盘上的页使用一定的替换策略加载到物理内存,并更新页表。缺页故障处理完毕后,CPU重新执行该条指令。![](https://img-blog.csdnimg.cn/direct/61b5474440e348c8b433257ac6490b90.png)
图 7.8.1 VM缺页处理流程
7.9动态存储分配管理
1.空闲列表管理:
- 空闲块链表:使用链表结构管理空闲内存块,空闲块按照地址或大小排序,方便查找和合并。
- 位图管理:使用位图记录内存块的使用情况,每一位表示一个固定大小的内存块是否空闲。
2.分区分配:
- 固定分区:将内存划分为若干个固定大小的分区,每个分区只分配给一个进程。
- 可变分区:内存不预先划分,在程序运行时根据需要动态分配大小合适的内存块。
3.堆管理:
- 单一堆:所有内存分配和释放操作都在一个连续的内存区域(堆)中进行。
- 多堆管理:根据内存块大小或使用频率,维护多个堆,分别管理不同类型的内存分配请求。
策略
首次适配(First-Fit):
从头开始搜索空闲列表,找到第一个大小合适的空闲块进行分配。
优点:实现简单,搜索速度快。
缺点:可能导致内存碎片。
最佳适配(Best-Fit):
搜索整个空闲列表,找到大小最接近且满足请求的空闲块进行分配。
优点:减少浪费的内存块大小,适用于小内存分配。
缺点:搜索时间长,可能导致大量小碎片。
最差适配(Worst-Fit):
搜索整个空闲列表,找到最大的空闲块进行分配。
优点:防止过多的小碎片,适用于大内存分配。
缺点:搜索时间长,可能浪费大块内存。
次最佳适配(Next-Fit):
从上次分配结束的位置继续搜索空闲列表,找到第一个大小合适的空闲块进行分配。
优点:避免频繁从头搜索,搜索速度适中。
缺点:可能导致内存碎片。
快速适配(Quick-Fit):
维护多个空闲块链表,每个链表管理相同大小的空闲块,直接查找合适的链表进行分配。
优点:分配速度快,适合频繁的小内存分配。
缺点:需要额外的内存管理结构,维护复杂。
结合策略与方法的综合管理
1.分区与适配策略结合:在可变分区管理中,结合首次适配、最佳适配等策略,提高内存分配效率。
2.堆与垃圾回收结合:在堆管理中,结合垃圾回收算法(如标记-清除、标记-整理、复制算法等),自动回收不再使用的内存,减少内存碎片。
通过采用不同的动态存储分配管理方法和策略,系统可以根据应用需求和内存使用情况,灵活高效地管理内存资源,提高程序运行效率和系统稳定性。
7.10本章小结
本章分析了hello的存储管理。介绍了Intel逻辑地址到线性地址的变换-段式管理,以及TLB与多级页表支持下的VA到PA的转换,分析了三级Cache支持下的物理内存访问。简要分析了hello的fork和execve内存映射,介绍了缺页故障与缺页中断处理程序的相关知识。并详细描述了系统如何应对缺页异常,提升了我对虚拟内存和存储器层次结构的掌握。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
在 Linux 中,I/O 设备管理通过将设备模型化为文件来实现,这种模型化使得所有 I/O 设备(例如网络、磁盘和终端)都可以像文件一样进行处理。设备模型化为文件的方式简化了系统的设计和使用,使得所有的输入和输出都能以统一且一致的方式进行管理。
设备的模型化:文件
一个 Linux 文件是一个包含 m 个字节的序列。所有 I/O 设备都被模型化为文件,所有的输入和输出都被当作对相应文件的读和写来执行。这种方法允许 Linux 内核引入一个简单、低级的应用接口,称为 Unix I/O,使得所有的 I/O 操作都能够以统一的方式执行。
Unix 文件类型:
1. 普通文件(Regular File):包含任意数据。应用程序通常区分文本文件(仅含有 ASCII 或 Unicode 字符)和二进制文件(包含其他所有数据的文件)。
2. 目录(Directory):包含一组链接(link)的文件,每个链接将一个文件名映射到一个文件,这个文件可能是另一个目录。每个目录至少含有两个条目:一个指向该目录自身,另一个指向父目录。
3. 套接字(Socket):用于与另一个进程进行跨网络通信的文件。
4. 命名管道(Named Pipe):用于进程间通信的文件。
5. 符号链接(Symbolic Link):指向另一个文件的指针文件。
6. 字符和块设备(Character and Block Device):终端(字符设备)和磁盘(块设备)。
7. FIFO(管道):用于进程内部通信的文件类型。
设备管理:Unix I/O 接口
Linux 通过 Unix I/O 接口管理设备,主要操作包括打开文件、读写文件、改变文件位置和关闭文件。
1. 打开文件:应用程序通过要求内核打开相应的文件,宣告它想要访问一个 I/O 设备。内核返回一个小的非负整数,称为文件描述符,用于标识该文件。内核记录有关这个打开文件的所有信息。
2. 预设文件描述符:每个由 shell 创建的进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)和标准错误(描述符为 2)。
3. 改变文件位置:对于每个打开的文件,内核保持一个文件位置指针 k,初始值为 0。该指针指示下一次读或写操作的位置。
4. 读写文件:读操作将从文件当前位置 k 开始复制 n 个字符到内存中,然后更新 k 的值(k += n)。对于大小为 m 字节的文件,当 k >= m 时,读操作会触发 EOF(文件结束)条件,应用程序可以检测到这个条件。
5. 关闭文件:当应用程序完成对文件的访问后,通知内核关闭该文件。无论一个进程因何种原因终止,内核都会关闭所有打开的文件并释放它们的内存资源。
将设备映射为文件的方式,使得 Linux 可以提供一个一致且简单的接口来管理不同类型的 I/O 设备,大大简化了应用程序的开发和系统的管理。
8.2 简述Unix IO接口及其函数
Unix I/O 接口提供了一种统一的方式来处理文件和设备的输入输出操作。所有的设备(如硬盘、终端、网络接口等)都被抽象为文件,通过文件描述符进行访问。主要的 Unix I/O 函数包括:
1.打开文件:open
用途:打开一个文件或设备,以便进行读写操作。
原型:int open(const char *pathname, int flags, mode_t mode);
返回值:成功时返回一个文件描述符,失败时返回 -1。
2.关闭文件:close
用途:关闭一个已经打开的文件描述符。
原型:int close(int fd);
返回值:成功时返回 0,失败时返回 -1。
3.读取文件:read
用途:从一个打开的文件描述符中读取数据。
原型:ssize_t read(int fd, void *buf, size_t count);
返回值:返回读取的字节数,失败时返回 -1,EOF 返回 0。
4.写入文件:write
用途:向一个打开的文件描述符写入数据。
原型:ssize_t write(int fd, const void *buf, size_t count);
返回值:返回写入的字节数,失败时返回 -1。
5.设置文件位置:lseek
用途:改变文件描述符的当前读写位置。
原型:off_t lseek(int fd, off_t offset, int whence);
参数 whence 的值:
SEEK_SET:从文件开头开始计算偏移量。
SEEK_CUR:从当前位置开始计算偏移量。
SEEK_END:从文件末尾开始计算偏移量。
返回值:成功时返回新的偏移量,失败时返回 -1。
6.文件控制操作:fcntl
用途:对文件描述符进行控制操作,如设置文件描述符标志、获取和设置文件状态标志等。
原型:int fcntl(int fd, int cmd, ... /* arg */ );
常见命令(cmd):
F_GETFD:获取文件描述符标志。
F_SETFD:设置文件描述符标志。
F_GETFL:获取文件状态标志。
F_SETFL:设置文件状态标志。
返回值:成功时返回相应的值,失败时返回 -1。
7.同步文件:fsync 和 fdatasync
用途:将文件描述符的数据同步到存储设备。
原型:
int fsync(int fd);
int fdatasync(int fd);
返回值:成功时返回 0,失败时返回 -1。
8.3 printf的实现分析
1.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 函数的形参列表里有 “...”,这是可变参数的一种写法,用于表示传递的参数个数不确定。va_list 的定义如下:
typedef char* va_list;
在 va_list arg = (va_list)((char*)(&fmt) + 4); 中,(char*)(&fmt) + 4) 获取的是可变参数中的第一个参数。
2.从vsprintf生成显示信息
调用 vsprintf 函数。vsprintf 的作用是格式化输出。它接受一个确定输出格式的格式字符串 fmt,并对变化的参数进行格式化,产生格式化输出,返回要打印的字符串的长度 i。
3.调用系统级I/O函数 write
write 函数将 buf 中的前 i 个字符,即要输出的格式化字符串,打印到屏幕上。查看 write 的反汇编代码,可以看到 write 通过执行 int INT_VECTOR_SYS_CALL 指令,引发一个异常处理程序的陷阱,系统调用内核程序 sys_call 函数:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
4.调用sys_call 函数
INT_VECTOR_SYS_CALL 的实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx c
all [sys_call_table + eax * 4]
add esp, 4 * 3 mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
这个过程的功能是显示格式化的字符串。
5.字符显示驱动子程序:从ASCII到字模库,再到显示VRAM(存储每一个点的RGB颜色信息)。
6.显示芯片按照刷新频率逐行读取VRAM,并通过信号线每一个点(RGB分量)传输到显示器上。
通过上述步骤,printf 函数实现了将格式化的字符串显示到屏幕上的功能。
8.4 getchar的实现分析
1. 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 实现通过 read 系统调用从标准输入读取字符,并缓存到静态缓冲中。以下是其工作原理:
buf 是一个静态缓冲区,用于存储从标准输入读取的字符。
bb 是指向缓冲区中当前字符的指针。
n 是缓冲区中剩余的字符数。
当 n 为 0 时,表示缓冲区为空,需要通过 read 从标准输入读取新的数据块。读取的数据块大小为 BUFSIZ,并更新指针 bb 和字符计数 n。如果缓冲区中有字符,返回当前字符并递增指针 bb,否则返回 EOF。
2. 调用 read 函数
getchar 函数依赖于 read 系统调用来实现从键盘缓冲区读取数据。以下处理流程:
异步异常键盘中断的处理
当按键被按下时,键盘控制器会发送一个中断请求(IRQ),触发中断处理程序。具体步骤如下:
- 键盘中断处理子程序
- 键盘控制器发送中断信号。
- 中断处理程序被调用,读取按键扫描码。
- 将按键扫描码转换为 ASCII 码。
- 将 ASCII 码保存到系统的键盘缓冲区中。
getchar 调用 Unix I/O read 系统函数
getchar 函数调用 read 系统函数,从键盘缓冲区中读取按键的 ASCII 码,直到读取到回车键后返回。具体步骤如下:
1. 调用 read 系统函数
getchar 调用 read(0, buf, BUFSIZ) 从标准输入读取数据。
read 函数将数据从键盘缓冲区复制到用户空间的缓冲区 buf 中。
读取到的数据量由 n 表示。
2. 等待输入
read 系统调用会阻塞程序的执行,直到从键盘缓冲区读取到数据。
当用户按下回车键时,输入的字符包括回车键会被返回。
3. 返回字符
getchar 返回读取到的字符,并更新缓冲区指针和计数器。
这样,getchar 就实现了从标准输入读取一个字符的功能。键盘中断处理和 read 系统调用的结合,使得 getchar 可以高效地处理用户输入并返回按键字符。
8.5本章小结
本章详细探讨了 Linux 的 I/O 设备管理方法,通过将所有 I/O 设备(如网络、磁盘和终端)模型化为文件,简化了系统设计,实现了统一的输入输出操作管理。设备类型包括普通文件、目录、套接字、命名管道、符号链接、字符和块设备以及 FIFO,每种类型都有其独特的用途。
通过分析 Unix I/O 接口及其函数(如 open、close、read、write、lseek 和 fcntl),我们了解了这些函数如何为文件和设备的 I/O 操作提供一致的接口,提升了系统的通用性和灵活性。
在 printf 函数的实现分析中,我们展示了如何生成格式化输出并通过系统调用将其输出到屏幕。对于 getchar 函数,我们解析了其通过 read 系统调用从标准输入读取字符的机制。
通过本章的学习,我加深了对 Linux I/O 设备管理的理解,设备模型化为文件的理念和 Unix I/O 接口的实现,使得 Linux 能够高效地管理多种 I/O 设备,提升了系统性能和开发便利性。
至此分析完hello的一生。
结论
"hello" 的精彩一生:计算机系统的全景展示
在 "hello" 程序的生命历程中,它经历了多个重要阶段,生动地展示了计算机系统的各个方面:
1. 源代码编写:
程序员在编辑器中使用高级语言编写了 hello.c 文件,并将其存储在内存中。这是 "hello" 生命的起点。
2. 预处理:
通过预处理器对 hello.c 文件进行处理,生成了修改后的文本文件 hello.i。在这个阶段,注释被删除,宏替换完成,"hello" 开始有了初步的形态。
3. 编译:
编译器将 hello.i 翻译成汇编语言,生成了 hello.s 文件。此时,"hello" 从高级语言变成了更接近机器语言的汇编代码。
4. 汇编:
汇编器将 hello.s 转换成机器可读的可重定位目标文件 hello.o。在这一阶段,"hello" 已经具备了运行的基础结构。
5. 链接:
链接器将 hello.o 与其他必要的目标文件链接在一起,生成了最终的可执行文件 hello。现在,"hello" 已经准备好运行了。
6. 程序执行:
在 shell 中运行 hello 程序时,系统创建了一个子进程,并通过 execve 系统调用将该程序变成一个独立的进程,赋予了 "hello" 独立运行的能力。
7. 进程执行:
"hello" 程序在获得 CPU 时间片后开始执行自身的逻辑,通过三级缓存访问内存,将虚拟地址映射为物理地址,实现高效的内存操作。
8. 信号处理:
在运行过程中,"hello" 可以接收到各种信号(如终止信号、挂起信号等),并根据不同的信号进行相应的处理,这展示了程序对异常控制流的管理。
9. 程序终止:
当使用 kill 命令终止 hello 进程时,系统回收子进程资源,标志着 "hello" 的生命周期结束。
理解与感悟
"hello" 程序的一生反映了计算机系统中程序的创建、编译、链接、执行和终止的完整过程。通过对这一过程的拆解和实验,我深入了解了计算机系统的内部工作原理,增强了对代码执行流程和底层交互的理解。通过对这一生命过程的拆解和实验,我们深入学习了计算机系统的内部工作原理,对代码的执行流程和计算机底层的交互有了更深的理解。这样的学习体验有助于我们编写更高效、可靠的代码,并且拓展了我们对计算机系统的认知。
附件
hello.i 预处理产生的文件
hello.s 编译产生的汇编代码文件
hello.o 汇编产生的可重定位目标文件
hello.o.elf hello.o文件的ELF格式
hello.o.asm hello.o反汇编生成的文本文件
hello 链接产生的可执行目标文件
hello.elf hello文件的ELF格式
hello.asm hello反汇编生成的文本文件