本文通过构建一个简单的Hello程序,深入探讨了其从编写到执行的整个过程,揭示了计算机系统的奥秘。首先,我们编写了一个名为hello.c的源文件,通过预处理、编译、汇编和链接等步骤,将其转化为可执行文件hello。在此过程中,我们详细分析了数据类型和各种操作,包括赋值、类型转换、算术和位级操作、关系操作、指针数组结构操作以及控制转移和函数操作等。
汇编阶段,我们将汇编语言转换为机器语言指令,并生成可重定位目标程序hello.o。并深入分析了hello.o与hello文件的ELF格式,通过反汇编将他们进行对比,理解编译器的优化能力和代码中的低效率。通过遍历hello的执行过程,我们详细了解了重定位过程和动态链接分析。我们还对hello的虚拟地址空间进行了详细探讨,理解了从逻辑地址到物理地址的映射关系。
整个研究过程展示了编译、链接、加载和执行的每个细节,使我们更加深入地理解了计算机系统的运行机制和优化策略。通过这次探索,我们对Hello程序的高贵出身和坎坷一生有了更深的体会,体会到计算机系统中的每一个环节都在为其运行保驾护航。无论是OS的进程管理、存储管理还是IO管理,都在默默支持着这个简单却意义深远的程序的执行。
关键词:预处理;编译;汇编;链接;执行流程;动态链接;虚拟内存管理;I/O
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P(From Program to Process):即将源程序文件hello.c翻译成可执行文件Hello,并创建进程进行运行的过程。整个翻译过程分为四个阶段:
- 预处理器cpp根据以字符#开头的命令修改原始c程序,得到预处理文件Hello.i。
- 编译器cc1将预处理文本文件翻译成包含汇编程序的文本文件Hello.s。
- 编译器as将hello.s翻译成机器语言指令,并打包成可重定位目标文件Hello.o。
- 链接器ld将可重定位目标文件hello.o和printf.o进行链接,生成可执行文件Hello,可加载到内存并由系统执行。
在Bash(shell)中,输入./hello命令,OS使用fork为Hello创建进程。便实现了hello.c(Program)到进程(Process)。
020(From Zero-0 to Zero-0):hello文件相关内容最开始并不在内存之中(From Zero-0),当执行hello文件时,OS为Hello fork进程,并使用execve加载程序到该进程,同时完成虚拟内存到物理内存的映射,即可开始运行相关代码。当执行完后,hello被回收并由内核删除它在内存中的相关内容(to Zero-0)。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:
处理器:12th Gen Intel(R) Core(TM) i7-12700H 2.30 GHz
机带RAM:16.0 GB
系统类型:64位操作系统,基于x64的处理器
软件环境:
Windows 11 23H2
Ubuntu 20.04.6 LTS
调试工具:
Visual Studio 2022 64-bit;Visual Studio Code;
gedit,gcc,notepad++,readelf, objdump, hexedit, edb
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 功能 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | hello的ELF格式 |
hello.o.elf | hello.o的ELF格式 |
hello.asm | hello的反汇编文件 |
hello.o.asm | hello.o的反汇编文件 |
1.4 本章小结
本章对Hello进行了一个总体的概括,首先介绍了P2P、020的意义和过程,介绍了论文所使用的硬件环境、软件环境和调试工具,最后简述了从.c文件到可执行文件的中间结果文件及文件作用。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
作为编译过程的初始阶段,预处理的主要目的是对源代码进行一系列预处理操作,以生成更适合编译器处理的代码。在编译器解析源代码之前,预处理器会处理以“#”符号开头的预处理指令,如#include和#define,这些指令不会被编译成机器代码,而是在编译过程读取头文件内容插入程序文本中,得到.i的文本文件。
预处理的作用:
预处理在编译过程中起着重要作用。通过宏定义和替换,预处理器能提高代码的可读性和维护性,并减少重复代码;文件包含使得代码的重用和模块化得以实现;条件编译允许根据不同条件选择性编译代码,以适应不同平台或环境;避免重复包含通过头文件保护防止同一头文件被多次包含,从而避免编译错误;消除注释使得编译器在解析代码时不受注释干扰,提高编译效率。预处理过程中并未直接解析程序源代码的内容,而是对源代码进行相应的分割、处理和替换。简单来说,预处理是一个文本插入与替换的过程,生成的hello.i文件仍然是文本文件。通过这些功能,预处理使得源代码更加简洁、灵活和高效。
2.2在Ubuntu下预处理的命令
预处理的命令:cpp hello.c > hello.i
图1 预处理结果
或者gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
hello.i的文件相比于hello.c的文件,行数急剧增加,从24行扩展到3061行,并且hello.i中main函数为于文件末尾,与hello.c文件不同的是删除了空格符号。
图2 hello.c 图3 hello.i
位于main函数前的注释被全部删除。对于头文件,cpp 会按顺序删除指令 #include <stdio.h>、#include <unistd.h> 和 #include <stdlib.h>,然后在 Ubuntu 系统的默认环境变量中寻找相应的 .h 文件,具体到三个头文件,寻找路径分别是:usr/inlcude/stdio.h,usr/include/unistd.h和usr/inlcude/stdlib.h。如果文件中有 #define 语句,则继续处理,直到所有 #define 语句都被替换。对于大量使用 #ifdef 和 #ifndef 条件编译语句的部分,cpp 会根据条件值判断是否执行相应逻辑。最终,hello.i 文件中包含命令行参数、环境变量、处理过的头文件内容、绝对路径显示、数据类型说明、结构体定义、引用的外部函数声明,以及未修改的源代码。
图4 hello.c
图5 hello.i
2.4 本章小结
本章介绍了预处理的概念与作用,分析了预处理通过头文件的展开,宏替换,去掉注释和条件编译使得源代码更加简洁、灵活和高效。并给出了Ubuntu中生成预处理文件的指令,对比解析了hello.c和hello.i文件,详细说明了预处理的内涵。
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译是指将预处理后的 .i 文件转换为汇编语言程序 .s 文件的过程。编译器ccl通过词法分析、语法分析、语义分析和中间代码生成,将预处理后的源代码翻译成等价汇编代码。在 hello.s 中,以文本的形式描述了一条条低级机器语言指令。
编译的作用:
主要作用包括语法检查,确保代码符合语言的语法规则,并报告错误信息;生成汇编语言程序,为后续的汇编和链接步骤做好准备;通过优化代码,提高程序的执行效率。编译确保代码正确、高效地运行,并生成可以进一步处理的汇编语言程序文件。
3.2 在Ubuntu下编译的命令
编译的命令:/usr/lib/gcc/x86_64-linux-gnu/9/cc1 hello.i -o hello.s
图6 编辑结果
3.3 Hello的编译结果解析
3.3.1 hello.s文件结构
分析如下:
伪指令 | 含义 |
.file | 源文件 |
.text | 代码节 |
.section .rodata | 只读数据 |
.align | 对齐方式 |
.string | 字符串声明 |
.globl | 全局变量 |
.type | 符号类型声明 |
3.3.2 数据类型
3.3.2.1常量
常量大多是以立即数的形式出现在汇编代码中,而字符串常量则存在于只读数据段中。
3.3.2.1.1 if中的常量
图7 if中常量
if中对argc与常量5进行比较,编译器使用立即数$5代表常量5。
图8 常量汇编代码
3.3.2.1.2 for中的常量
图9 for中常量
本质上仍是比较操作,故与if中的常量表示一样。
图10 常量汇编代码
3.3.2.1.3 字符串常量
图11 字符串常量
printf函数格式串存放在只读数据段。
LCO表示的是argc(也即输入参数)不符合要求的输出:.LCO的汉字采用utf-8编码,编码为三个字节。
LC1表示的是argc符合要求的输出:在文件中正常显示。
字符串常量使用时通过加载其位置进行取出。
图12 使用字符串常量
图13 字符串常量存储位置
3.3.2.2变量
变量分为全局变量,局部变量,静态变量。已初始化的全局变量和静态变量存放在.data节,未初始化的全局变量和静态变量和初始化为0的全局变量和静态局部变量存放在.bss节,而局部变量在运行时存放在栈中管理。
在hello.c中,并没有使用全局变量和静态变量,故hello.s中没有.data节和.bss节,而使用的局部变量i,通过如下汇编指令,for循环前令i = 0的操作,可以看到i被保存在栈中%rbp - 4的位置上,并且它占据了4个字节的空间。通过基于%rbp计算有效地址的方式,实现了对局部变量的引用。
图14 局部变量赋初值
3.3.2.3基本类型
基本数据类型包括整数类型(如 int、short、long、unsigned),浮点类型(如 float、double)和字符类型(如 char)。
在hello.c中,i和main函数中的argc参数为整型数据(int),main中的argv[ ]为指针数组,同时文件中含有0,5,10等常量整数,并在只读数据段存储着字符串常量。
3.3.2.4宏
宏变量是一种通过预处理器指令 #define 定义的常量或表达式,在编译时由预处理器进行文本替换。它们用于提高代码的可读性、简化代码维护和减少重复代码。宏替换在预处理阶段进行,没有类型检查,仅仅是文本替换。hello.c程序无宏变量的使用。
3.3.3赋值操作:
通过=将右边的值赋给左边的变量
全局变量在.text被声明,并在.data段设置其初值。不赋初值则会被存放到.bss段。
赋值操作在汇编代码中通过mov指令完成。不同大小的数据使用的mov指令如表格所示:
图15 赋值操作指令
hello.c中只有一个赋值表达式i=0,i为int类型,大小为双字,故采用movl
图16 hello中赋值操作指令
3.3.4 算术操作与位操作
对应汇编代码中的指令如下:
图17 运算操作指令
复合赋值操作是指先执行运算符指定的运算,然后再将运算结果存储到运算符左边操作数指定的变量中。对于“+=、-=、*=、/=、%=”复合赋值,记运算符为·,a·=b等价于a=a·b。“|=、<<=”等位运算符与赋值符的复合同理。
hello.c在循环中每次循环对循环变量i进行+1操作
图18 hello.c中的变量
hello.s中使用addl实现。
图19 +1的汇编代码
3.3.5 关系操作
常用的关系操作指令有cmp和test,对应汇编指令如下:
图20 CMP指令
它们只设置条件码而不改变任何其他寄存器。CMP指令根据两个操作数之差来设置条件码。TEST指令的行为与AND指令一样,除了它们只设置条件码而不改变目的寄存器的值。
hello.s使用cmp实现两个比较argc!=5和i<10。
3.3.6 控制转移
使用关系操作指令和JMP一同实现,JMP指令如下:
图21 JMP指令
if语句判断传入参数数量是否为5
图22 if控制转移
汇编代码中,通过cmp将argc与立即数5比较,通过条件码进行je等于则跳转.L2,否则进行后续指令。
图23 if汇编代码
for循环通过i是否小于10来判断是否进入循环
图24 for控制转移
汇编代码如下:
图25 for汇编代码
汇编代码中,cmpl每次判断i(存放在%rbp-4)是否满足循环条件(小于等于9),满足则跳转进入循环.L4,不满足则执行后续指令。等价goto代码为:
int i=0;
goto test;
loop:
printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);
sleep(atoi(argv[4]));
i++;
test:
if (i <=9)
goto loop;
同样通过JMP指令,switch通过跳转表和偏移量进行跳转,continue和break通过标记循环起点和循环终点进行跳转,continue指令直接跳转则循环起点,break指令直接跳转至循环终点。
3.3.7 数组/指针操作
hello.c中使用的数组/指针为main中的*argv[ ]指针数组
图26 hello中的数组/指针
汇编代码中的movl %edi, -20(%rbp)和movq %rsi, -32(%rbp)。分别是将寄存器%edi的内容赋值给-20(%rbp)指针指向的地址,将寄存器%rsi的内容赋值给-32(%rbp)指针指向的内容。即将argc和argv传入栈中
图27 main的参数传递
对于数组C[n],起始地址为x0,意味着在内存中为其分配了一个大小为L·n字节的连续区域,这里的L是数据类型T的大小。数组C可以被视为指向该数组开头的指针,其值为x0。数组元素可以通过0到n-1的整数索引i进行访问,每个元素被存放在地址为x0 + L·i的地方。
指针存储的是另一个变量的内存地址。通过指针,可以直接访问和操作该地址处的值。广泛用于动态内存分配、数组和字符串操作、函数参数传递等场景。
argv[]字符型指针数组中每个元素对应一个指针,通过%rbp-32找到数组首地址,加上偏移量来得到数组中的指针,%rbp-32+24为argv[3],%rbp-32+16为argv[2],%rbp-32+8为argv[1],对指针的地址进行访问,便得到指针指向的字符串内容。
图28 数组元素调用汇编代码
3.3.8 函数操作
图29 函数调用栈结构
函数P调用函数Q的过程:
首先,函数P通过mov指令准备传递给函数Q的参数,并将其存放在适当的位置,如寄存器(%rdi、%rsi、%rdx、%rcx、%r8、%r9)或堆栈中。接着,函数P保存当前的CPU寄存器状态和栈帧,以便函数Q执行完毕后能够恢复这些状态,并为函数Q创建新的栈帧,调整堆栈指针以为其局部变量和返回地址预留空间。然后,函数P使用跳转指令将控制权转移给函数Q,函数Q从其入口地址开始执行代码,完成任务后将结果存储在预定的位置。随后,函数Q执行返回指令,将控制权返回给函数P,堆栈指针和栈帧指针被恢复到函数P调用前的状态。最后,函数P从寄存器或堆栈中获取函数Q的返回结果,并继续执行后续代码。
下面分析hello.s中的函数调用:
- main函数
图30 main函数
参数传递:第一个参数是argc(类型int),第二个参数是argv[](类型char *),分别存放在寄存器%edi和%rsi中;
Call调用:main函数被系统函数__libc_start_main调用,call指令将main函数的地址分配给%rip,随后调用main函数。
局部变量与返回:使用i作为局部变量,调用leave指令,恢复栈空间为调用之前的状态并返回0。
- printf函数
第一处printf函数调用
图31 第一处printf函数
在汇编代码中优化后采用puts函数实现
参数传递:将.LC0处存放的字符串常量("用法: Hello 学号 姓名 手机号 秒数!\n")首地址传递给寄存器%rdi。
图32 第一处printf函数汇编代码
第二处printf函数调用
图33 第二层printf函数
没有更换为puts函数,直接调用printf函数
参数传递:将.LC1处存放的字符串("Hello %s %s %s\n")首地址作为第一个参数,并argv[1],argv[2],argv[3],作为第二三四个参数。
图34 第二处printf函数汇编代码
- exit函数
exit终止程序,将1传递到%rdi后调用函数。
图35 exit函数
图36 exit函数汇编代码
- sleep函数和atoi函数
这里使用了双重调用,将argv[4]指针(存放在%rbp-32+32的位置)内的值传递给%rdi作为atoi的参数,再将atoi的存于%rax的返回值作为参数,传递给%rdi,再调用sleep函数。
图37 sleep函数
图38 sleep函数汇编代码
- getchar函数
没有传入参数,直接调用函数,执行函数内容。
图39 getchar函数
图40 getchar函数汇编代码
3.4 本章小结
本章深入探讨了编译的概念,并展示了如何将hello.c源文件编译为hello.s汇编文件。理解汇编代码有助于分析性能、洞察编译器优化能力,并发现潜在的低效率。同时,对编译的基本概念有了深入理解,还针对其中的数据类型(如整数、字符串、数组)和操作(如赋值、类型转换、算术和位级操作、关系操作、指针数组结构操作以及控制转移和函数操作)进行了细致的分析和探讨,帮助理解和掌握编译。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
汇编是一种将高级语言编写的源代码转换为机器语言的过程。在汇编过程中,汇编器(as)将汇编语言源文件(.s文件)转换为可重定位目标文件(.o文件),目标文件包含了可执行程序的机器语言指令,并在接下来链接成最终的可执行文件。
汇编的作用:
将人类可读的汇编代码转换为计算机可执行的机器语言,完成程序的编译过程,直接操作计算机硬件,对底层硬件进行控制。
4.2 在Ubuntu下汇编的命令
汇编的命令:
as hello.s -o hello.o
图41 汇编结果
或者gcc -o hello.s -o hello.o
4.3 可重定位目标elf格式
4.3.1可重定位目标文件的elf格式:
ELF头 |
.text |
.rodata |
.data |
.bss |
.symtab |
.rel.text |
.rel.data |
.debug |
.line |
.strta |
节头部表 |
4.3.2读取可重定位目标文件
通过命令readelf -a hello.o >hello.o.elf 可重定位目标文件输出定向到文本文件hello.o.elf中。
图42 生成elf文件
4.3.3 ELF文件结构分析
4.3.3.1 ELF头
以 16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
图43 elf头
4.3.3.2 节头部表
节头部表包含了文件中各个节的信息,每个节头描述了对应节的属性和位置等细节。每个节头通常包含节名、节类型、节大小、节的属性(读写权限)、节在文件中的偏移、节的地址对齐要求以及其他与节相关的标志。
图44 节头部表
4.3.3.3 节
.text:已编译程序的机器代码。
.rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表。
.data:已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss中。
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态C变量。
.symtab:一个符号表,他存放在程序中定义和引用的函数和全局变量的信息
.eh_frame:一个用于异常处理的节。具体来说,它用于存储与栈展开(stack unwinding)有关的信息。栈展开是当异常发生时,从当前堆栈帧回溯到之前的堆栈帧的过程。
.comment:一个特殊节,用于存储编译器或工具链的注释信息。它通常包含编译器版本号、编译器名称、编译选项等信息。
4.3.3.4 重定位节
重定位节包含如下信息:偏移量,需要进行重定位的地址在节中的偏移量。信息,重定位到的目标在.symtab中的偏移量。类型,重定位的类型,指示如何计算新地址。符号,需要被解析的外部符号。附加值,用于地址计算的附加值。
重定位条目格式为:
typedef struct{
long offset; 偏移量
long type:32, 类型
symbol:32; 符号
long addend; 附加值
}Elf64_Rela
图45 重定位条目
hello.o中.rela.text有8重定位信息,6条为外部定义函数引用,2条为printf中字符串引用。.rela.eh_frame为需要在链接时调整的地址和偏移量,以确保异常处理和栈展开机制能够正常工作。
R_X86_64_PC32为普通32位PC相对地址引用;
R_X86_64_PLT32专门用于调用外部函数,通过 PLT进行间接跳转。
计算公式为S + A - P,对于前者S是符号的地址,对于后者S是 PLT 条目的地址,A是重定位条目的附加值,P是需要重定位的指令地址(当前指令的地址)。
R_X86_64_PLT32类型的函数第一次调用时,通过GOT和PLT协同工作,延迟绑定它的运行地址。
4.3.3.5 符号表
.symtab是一个符号表,存放着程序中定义和引用的函数和全局变量的信息。
图47 符号表
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
4.4 Hello.o的结果解析
4.4.1 反汇编指令:objdump -d -r hello.o >hello.o.asm
图48 反汇编结果
4.4.2 操作数
图49 hello.s操作数 图50 hello.asm操作数
hello.s中,操作数为十进制,而hello.asm中操作数为十六进制。机器语言中以二进制来存储操作数,故反汇编时,以十六进制显示,而汇编文件是由预处理文件生成的,其中的数以十进制显示。
4.4.3 分支转移
图51 hello.s分支转移 图52 hello.asm分支转移
hello.s中通过使用.L2段标记进行跳转,而hello.o.asm中使用具体地址进行跳转。
4.4.4 字符串常量
图53 hello.s字符串常量
图54 hello.asm字符串常量
hello.s中,全局变量是通过%rip+.LC0地址访问,而hello.o.asm中,是通过%rip+0地址访问,后面还有一个重定位条目,.rodata节数据地址在运行时才被确定,需要进行重定位,用0来进行占位,并在.text中标记。
4.4.5 函数调用
图55 hello.s函数调用
图56 hello.asm函数调用
hello.s中call函数调用后跟的是函数名,而hello.o.asm中,call后跟的是下一条指令的地址,并且还有一个重定位条目,因为调用的函数都是共享库函数,地址不确定,使用0进行占位,当链接时确定具体地址。
4.4.6 机器语言的构成,与汇编语言的映射关系
机器语言是计算机能够直接理解和执行的最低级别编程语言,由二进制代码组成,包括操作码、操作数和指令格式。操作码指定要执行的操作,操作数指示操作的目标,指令格式定义了操作码和操作数的结构。汇编语言是一种与机器语言一一对应的低级编程语言,用助记符和符号表示机器指令和操作数,使编写和阅读更容易。汇编器将汇编语言翻译成机器语言,将助记符转换为操作码,将符号转换为二进制表示。
4.5 本章小结
本章讨论了汇编及其具体过程。汇编器将汇编语言转化为机器语言指令,并将这些指令打包成可重定位目标程序,结果保存在 hello.o 文件中。同时,本章重点分析了可重定位目标文件的 ELF 格式,并通过对 hello.o 文件进行反汇编,将生成的 hello.o.asm 文件与之前生成的 hello.s 文件进行了比较。
第5章 链接
5.1 链接的概念与作用
链接的概念:
链接是指将一个或多个目标文件合并,并生成一个单一文件的过程,这个文件可被加载到内存并执行。在这个过程中,链接器(Linker)会处理符号解析、重定位、库链接等任务,以确保生成的可执行文件能够正确执行。
链接的作用:
链接器负责解析每个目标文件中的符号定义和引用,调整目标文件中的地址,解决目标文件间的符号冲突,并将所有必要的库链接进来。最终,链接器生成一个包含所有必要代码和数据的可执行文件,准备好供系统加载和执行。链接使得分离编译成为可能。可以将源文件,分解为更小、更好管理的模块,可以独立地修改和编译这些模块。链接可以执行于编译,加载和执行时。
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
图57 链接结果
或者使用:gcc hello.o -o hello
5.3 可执行目标文件hello的格式
5.3.1可执行目标文件的elf格式:
ELF头 |
.init |
.text |
.rodata |
.data |
.bss |
.symtab |
.debug |
.line |
.strtab |
节头部表 |
.text、.rodata和.data节与可重定位目标文件中的节类似,除了这些节已经被重定位到它们最终的运行时的内存地址外。.init节定义了一个小函数_init,程序初始化代码会调用它。因为可执行文件时完全连接的,所以无.rel节。
5.3.2读取可执行目标文件
readelf -a hello >hello.elf生成hello 程序的ELF 格式文件。
图58 生成elf文件
5.3.3 ELF文件结构分析
5.3.3.1 ELF头
hello.elf中的ELF头与hello.o.elf中的ELF头包含的信息种类基本相同。与hello.o.elf相比较,hello.elf中的基本信息未发生改变,而类型发生改变,程序头大小和节头数量增加,并且包括程序入口点,即当程序运行时的第一条指令的地址。
图59 elf头
5.3.3.2 节头
各节的基本信息均在节头部表(描述目标文件的节)中进行了声明。节头部表包括名称,大小,类型,全体大小,地址,旗标,偏移量,对齐等信息,链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
图60 节头
5.3.3.2 程序头部表
程序头部表描述了可执行文件的连续的片被映射到连续的内存段的映射关系。每一个表项提供了各段在虚拟地址空间大小和物理地址,标志,访问权限和对齐方式。我们可以以此读出各段的起始地址。
图61 程序头部表
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
5.3.3.3.dynsym
主要用于动态链接时的符号表。这个节包含了程序或共享库中所有动态符号的信息,这些符号在运行时需要解析或使用。
图62 .dynsym节
5.3.3.4 符号表
hello 中的符号表,与重定位目标文件不同,可执行文件中的符号表通常不包含局部符号和调试信息,因为这些信息在生成可执行文件时可能被剔除以减少文件大小。可执行文件的符号表中所有的符号都已解析,链接器已经解决了所有符号引用,因此不会有未定义的符号。
图63 符号表
5.4 hello的虚拟地址空间
hello的Linux x86-64内存映像如下图所示。没有展示出由于段对齐要求和地址空间布局随机化造成的空隙。
图64 虚拟地址空间
打开edb并加载hello文件
图65 hello的edb信息
查看本进程的虚拟地址空间各段信息。观察edb的Data Dump窗口。第一行和ELF头中的Magic是相同的,说明程序是从0x400000处开始加载的,到0x400fff结束,这之间的每一个节对应每一个节头部表的声明。
图66 虚拟地址空间各段信息
5.5 链接的重定位过程分析
5.5.1 反汇编指令:objdump -d -r hello
图67 反汇编结果
5.5.2 hello与hello.o反汇编对比
1)链接后的反汇编文件hello.asm中,多出了puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数静态的执行一些链接,并在加载时动态完成链接过程。
图68 .init节和.plt节
2)hello.asm文件中多出了.init节和.plt节, hello.o.asm中只有.text节
图69 各函数调用
3)调用函数变化
在链接过程中,链接器解析了重定位条目,hello.o文件中原本的占位0被更改为运行位置,重定位条目被删除,在hello文件中,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。
图70 hello.o函数调用
图71 hello函数调用
4)跳转指令变化
hello.asm才用虚拟地址进行跳转,并在机器码中使用pc相对寻址实现,hello.o.asm中采用的是从0开始的地址。
图72 hello.o跳转指令
图73 hello跳转指令
5.5.3 重定位过程
要合并相同的节,确定新节中所有定义符号在虚拟地址空间中的地址,还要对引用符号进行重定位(确定地址),修改.text节和.data节中对每个符号的引用(地址),而这些需要用到在.rel_data和.rel_text节中保存的重定位信息。
5.6 hello的执行流程
使用gdb执行hello函数,设置_start的断点,单步运行,
1.当输入为空时,调用函数顺序如下:
__libc_start_main
__GI___cxa_atexit
__internal_atexit
__lll_cas_lock
__internal_atexit
__lll_cas_lock
__internal_atexit
__new_exitfn
__internal_atexit
__libc_start_main
_setjmp
__sigsetjmp
__sigjmp_save
__libc_start_main
输出,用法: Hello 学号 姓名 手机号 秒数!,程序结束。
2.当输入学号 姓名 手机号 1时,调用函数顺序如下:
__libc_start_main
__GI___cxa_atexit
__internal_atexit
__lll_cas_lock
__internal_atexit
__lll_cas_lock
__internal_atexit
__new_exitfn
__internal_atexit
__libc_start_main
_setjmp
__sigsetjmp
__sigjmp_save
__libc_start_main
此时输出结果,输出十遍Hello学号 姓名 手机号,再输入回车,程序继续运行。
__GI_exit
__run_exit_handlers
__GI___call_tls_dtors
__run_exit_handlers
__lll_cas_lock
__run_exit_handlers
_fini
__run_exit_handlers
__lll_cas_lock
__run_exit_handlers
__lll_cas_lock
__run_exit_handlers
_IO_cleanup
_IO_flush_all_lockp
__lll_cas_lock
_IO_flush_all_lockp
_IO_cleanup
_IO_unbuffer_all
__lll_cas_lock
_IO_unbuffer_all
__lll_cas_lock
_IO_unbuffer_all
IO_validate_vtable
_IO_unbuffer_all
_IO_new_file_setbuf
_IO_default_setbuf
IO_validate_vtable
_IO_default_setbuf
_IO_new_file_sync
_IO_default_setbuf
__GI__IO_setb
_IO_default_setbuf
_IO_new_file_setbuf
_IO_unbuffer_all
__lll_cas_lock
_IO_unbuffer_all
IO_validate_vtable
_IO_unbuffer_all
_IO_new_file_setbuf
_IO_default_setbuf
IO_validate_vtable
_IO_default_setbuf
_IO_new_file_sync
IO_validate_vtable
_IO_new_file_sync
__GI__IO_file_seek
__lseek64
_IO_new_file_sync
_IO_default_setbuf
__GI__IO_setb
_IO_default_setbuf
_IO_new_file_setbuf
_IO_unbuffer_all
_IO_cleanup
__run_exit_handlers
__GI__exit
5.7 Hello的动态链接分析
对于动态共享链接库中的函数,编译器无法预测其运行时地址,因此需要添加重定位记录,等待动态链接器处理。GNU编译系统采用延迟绑定技术,将过程地址的绑定推迟到第一次调用时。这种延迟绑定的动机在于对于像libc.so这样的共享库输出的成百上千个函数中,典型应用程序只使用其中很少一部分。延迟绑定将函数地址的解析推迟到实际调用时,避免了动态链接器在加载时进行大量不必要的重定位。虽然第一次调用过程的运行时开销很大,但之后的每次调用只会花费一条指令和一个间接的内存引用。
在elf文件中找到.got和.got.plt的位置信息
图74 .got和.got.plt的位置信息
在edb的 DataDump 中找到该位置
图75 dl_init调用前位置信息
dl_init函数调用后,变化为
图76 dl_init调用后位置信息
分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
本章通过hello可执行程序实验,深入讲解了链接的概念、作用及其在Ubuntu下的命令行操作。详细分析了hello的ELF格式,并探讨了其虚拟地址空间知识。通过反汇编hello文件与hello.o文件的对比,深入理解了链接过程中的重定位机制。此外,遍历了hello的执行过程,并进行了动态链接分析。通过本章,将对链接过程有更加全面的理解。
第6章 hello进程管理
6.1 进程的概念与作用
进程是正在运行的程序的实例,是程序执行时的基本单位。每个进程拥有独立的地址空间,包括代码段、数据段、堆和栈。操作系统通过进程控制块来记录和管理每个进程的状态、资源和调度信息。进程可以处于创建、执行、挂起和终止等不同的状态,并且这些状态的转换由操作系统来管理。
进程的作用:
在现代计算机中,进程为用户提供了程序独占使用处理器和内存的假象,用户程序看起来像是系统中唯一运行的程序,处理器好像无间断地执行程序指令,程序中的代码和数据似乎是系统内存中的唯一对象。
每次用户通过向shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
进程提供给应用程序两个关键抽象:一个独立的逻辑控制流;一个私有的地址空间。这确保了进程之间的隔离和保护,提升了系统的安全性和稳定性。
6.2 简述壳Shell-bash的作用与处理流程
Shell 是一个交互型应用程序,Shell 应用程序提供了一个界面,用户可以通过这个界面进行系统的基本操作,它会解释命令并访问操作系统内核的服务,为用户提供简化了的操作。
Shell的处理流程
① 从Shell终端读入用户键盘输入的命令行。
② 以空格划分输入字符串,获得并识别所有的参数,传递给execve的argv向量。
③ 若输入参数为内置命令,则立即执行
④ 若输入参数并非内置命令,则调用相应的程序为其分配子进程并加载程序运行
⑤命令末尾没有&号,则shell使用waitpid(wait)等待作业终止后返回。
⑥如果命令末尾有&号,则后台运行,shell返回;
6.3 Hello的fork进程创建过程
执行中的进程调用fork()函数,就创建了一个子进程。其函数原型为pid_t fork(void);对于返回值,若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID。
对与hello,shell先判断./hello为非内置命令,然后shell试图在硬盘上查找该命令(即hello可执行程序),并将其调入内存,然后shell将其解释为系统功能调用并转交给内核执行。
在调用fork函数之后,系统创建了一个新的子进程,这个子进程与父进程在结构上极为相似。具体而言,子进程的虚拟地址空间完全映射自父进程,可以视为父进程虚拟地址空间的一个镜像或副本,其中包括了代码和数据段、堆内存、共享库以及用户栈的所有内容。此外,子进程还继承了父进程所有打开的文件描述符的副本,这使得子进程同样具有对父进程已打开文件的读写权限。然而,子进程与父进程之间最显著的差异体现在它们各自拥有不同的进程标识符(PID)。
然后再将hello程序加载到该进程中执行。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行可执行目标文件hello,带有列表argv和环境变量列表envp。函数原型为:int exeve(const char *filename, const char *argv[], const char *envp[])。
在execve加载了hello之后,它调用启动代码。启动代码设置栈,并将控制传递给hello的主函数(即main函数),该函数有以下原型:int main(int argv, char **argv, char **envp)。
具体过程为:加载器首先移除子进程当前占用的虚拟内存段,随后为进程创建一组全新的内存区域,包括代码段、数据段、堆和栈。这些新创建的堆和栈段被默认初始化为零值状态。接着,加载器将可执行文件的内容映射到虚拟地址空间的对应页面(按页大小进行映射),从而初始化新的代码段和数据段。完成这些设置后,程序控制将跳转到_start地址执行,_start函数随后调用系统提供的__libc_start_main函数以初始化运行环境。在环境初始化完毕后,它将调用用户定义的hello程序的main函数。程序在执行过程中,当需要内核介入时(如进行I/O操作或系统调用),控制将被交还给内核。
6.5 Hello的进程执行
逻辑控制流
在操作系统环境中,尽管同时运行着多个程序,我们通常倾向于认为每个进程都独自占用CPU、内存等资源。然而,从更微观的角度来看,当我们单步调试程序时,可以观察到一系列的程序计数器(PC)值,这些PC值的序列实际上构成了每个进程的逻辑控制流。但实际上,在计算机内部,多个程序的执行是并行进行的,但这并不意味着它们同时运行在同一处理器核心上。相反,这些进程的执行是交错的,它们轮流使用处理器资源。这意味着,在一个处理器核心中,一个进程会执行其逻辑控制流的一部分,然后被抢占(即被暂时挂起),以便让另一个进程有机会执行其逻辑控制流的一部分。这种轮流执行的方式确保了多个进程能够共享处理器资源,从而实现并行处理的效果。
图77 逻辑控制流
进程时间片
多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间叫做时间片。多任务也叫做时间分片。
调度:在操作系统管理进程的过程中,有时内核会做出一个决策,即中断当前正在运行的进程,并选择另一个之前被暂停或未执行的进程来继续执行。这个过程就被称为调度,而这个决策是由内核中专门的调度器代码来负责处理的。
上下文切换:当内核决定要调度一个新的进程来运行时,它会暂停当前正在执行的进程,并启动一个上下文切换的机制来安全地转移到新的进程。上下文切换过程通常包括以下几个步骤:
1)保存当前进程的上下文:系统会保存当前进程的状态信息,如寄存器值、程序计数器、内存管理信息等,以便后续能够正确地恢复该进程的执行。
2)恢复先前被抢占的进程的上下文:系统会从之前保存的上下文中恢复被抢占进程的状态信息,确保该进程能够从中断点继续执行。
3)将控制权传递给新恢复的进程:完成上下文恢复后,系统会将控制权移交给新恢复的进程,使其开始执行。
图78 上下文切换
用户态和核心态的转换
用户态是应用程序运行的环境。用户态进程只能访问受限的内存区域,并且不能直接与硬件进行交互。所有的系统资源(如内存、CPU等)的访问必须通过系统调用来请求操作系统的服务。
核心态是操作系统内核运行的环境。内核具有完全的权限,可以访问所有的内存和硬件设备。内核态的代码可以执行任何CPU指令,并访问任何内存地址。
用户态和核心态的转换是操作系统运行的基础机制之一,通过这种转换,操作系统能够在确保系统安全和稳定的前提下,提供应用程序所需的各种服务。这种分离和转换机制使得操作系统能够高效地管理系统资源,防止应用程序对系统的滥用。
hello中的用户态和核心态的转换
进程hello开始时在用户模式下运行,直到它执行特定的系统调用函数(sleep或exit)时,会触发一个陷入内核的操作。在内核中,相应的处理程序会处理这个系统调用请求。一旦系统调用完成,内核会执行上下文切换,将控制权安全地返回给进程hello,并使其从系统调用之后的下一条语句继续执行。
6.6 hello的异常与信号处理
hello 执行过程中可能出现四类异常:中断、陷阱、故障和终止。
1. 中断是来自 I/O 设备的信号,异步发生,中断处理程序对其进行处理,返 回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
2. 陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指 令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
3. 故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成 功,则将控制返回到引起故障的指令,否则将终止程序。
4. 终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程 序会将控制返回给一个 abort 例程,该例程会终止这个应用程序。
hello 执行过程中可能产生的信号:SIGINT,SIGSTP,SIGCONT
下面分析hello执行过程中对各种异常和信号的处理。
1.正常运行,输出十次信息后,输入任意符合停止。
图79 正常运行
2.中途乱按,程序会继续运行,但是getchar会读入之前缓冲区输入的符合,最后不需要再次输入符合即可结束。
图80 中途乱按
3.Ctrl-Z,内核向前台进程发送SIGSTP信号,前台进程被挂起。
图81 Ctrl-Z
4.ps打印当前进程的状态。
图82 ps
5.jobs显示当前 shell 会话中所有运行的作业的状态。
图83 jobs
6.fg 内核向前台进程发送SIGCONT信号,前台进程继续执行。
图84 fg
7.Ctrl-C,内核向前台进程发送SIGINT信号,前台进程被中断。
图85 Ctrl-C
8.pstree打印进程树
图86 pstree
9.kill,向进程的pid发送相应的信号,将hello杀死。
图87 kill
6.7本章小结
本章深入探讨了hello程序的进程管理机制。进程拥有两个核心抽象:程序仿佛独占整个处理器,也仿佛独占整个内存系统。每个进程都拥有其独特的上下文环境,这是操作系统进行进程调度时的重要依据。通过上下文切换,操作系统能够高效地管理多个进程,确保它们能够有序、公平地共享系统资源。而用户与操作系统的交互则主要通过shell实现,shell通过调用fork函数创建新的进程,并使用execve函数来加载并执行特定的程序。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:
每一个逻辑地址都由一个段和偏移量组成,偏移量指明了从段开始的地方到实际地址之间的距离,通常在编译时由编译器生成。表示为 [段标识符:段内偏移量]。为hello.o中的地址。
线性地址:
线性地址是逻辑地址向物理地址转换过程中的一个中间表示。hello程序生成的逻辑地址,也就是段内的偏移量,加上其对应段的基地址后,就构成了线性地址。
虚拟地址:
虚拟内存为每一个运行的程序都分配了一个统一且私有的地址空间,使得每个程序都仿佛拥有自己独立的内存区域。在这个地址空间中,每一个字节都有其对应的地址,我们称之为虚拟地址。虚拟地址由虚拟页面偏移量VPO(用以指示页面内的具体位置),和虚拟页号VPN(用以标识不同的页面)构成,VPN可以分为TLB索引(TLBI)和TLB标记(TLBT),它们共同协助高速缓存(TLB)来加速虚拟地址到物理地址的转换过程。hello反汇编文件中的地址为虚拟地址。
物理地址:
物理地址是CPU外部地址总线上用于寻址物理内存的实际地址信号。它是地址转换的最终输出。在启用分页机制的情况下,hello的线性地址会经过页目录和页表的转换,变为物理地址;若分页机制未启用,则hello的线性地址直接等同于物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址为 段标识符:段内偏移量。
线性地址=段地址+偏移地址
实模式:逻辑地址 CS:EA = 物理地址 CS*16+EA
CS 是代码段,EA 是段内偏移量。
保护模式:线性地址通过段寄存器和段描述符表(GDT/LDT)进行转换
段寄存器中段选择符格式如下:
15 14 3 | 2 | 1 0 |
索引 | TI | RPL |
CS(代码段):程序代码所在段;SS(栈段):栈区所在段;DS(数据段):全局和静态数据所在段;其他三个段寄存器 ES、GS 和 FS 可指向任意数据段。
索引:决定了要访问的段描述符在GDT或LDT中的位置
表指示符(TI):决定了段选择符是指向GDT还是LDT
请求特权级(RPL):数值越低特权级别越高
具体转换过程如下:
图88 逻辑地址到线性地址的变换
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。VM系统将虚拟内存分割为虚拟页,同时,物理内存也被分割为大小相等的页。虚拟内存和物理内存通过MMU建立起相应的映射关系,虚拟页面地集合被分为三个不相交的子集:已缓存、未缓存和未分配。页表实现从虚拟页到物理页的映射,由页表条目组成。
虚拟地址可划分为两个部分:一个是VPN,另一个则是VPO。VPN和VPO的具体位数是由计算机系统的特性来决定的。VPO与PPO大小相同。通过VPN索引,查询页表中的页表条目(PTE)可以获取PPN。
具体步骤如下:
第1步:处理器生成一个虚拟地址,并把它传送给MMU
第2步:MMU生成PTE地址,并从高速缓存/主存请求得到它
第3步:高速缓存/主存向MMU返回PTE;
第4步:MMU构造物理地址,并把它传送给高速缓存/主存
第5步:高速缓存/主存返回所请求的数据字给处理器
图89 线性地址到物理地址变换
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE,将虚拟地址翻译为物理地址。如果PTE碰巧缓存在L1中,那么开销就下降到1个或2个周期。于是在MMU中包括了一个关于PTE的小的缓存,称为快表TLB,每一行都保存着一个由单个PTE组成的块。
图90 虚拟地址划分
TLB中的PTE由VPN进行索引,如果命中,则直接将对应PPN送至MMU,不需要经过主存。如果不命中再从高速缓存/内存中将PTE 复制到 TLB。
如果使用的只是虚拟空间的一小部分,但是需要将页表驻留在内存中,为了减小内存空间的占用,采用多级页表。只有一级页表才需要总是在主存中,其他页表在使用时调入,减少了主存压力。
对于四级页表,虚拟地址被划分为 4 个 VPN 和 1 个 VPO。每个 VPNi 都是一个到第 i 级页表的索引,第 j 级页表中的每个 PTE 都指向第 j+1级某个页表的基址,第四级页表中的每个 PTE 包含某个物理页面的 PPN,或者一个磁盘块的地址。VA到PA的变换中,由CR3确定第一级页表的起始地址,逐级进行查询,直到在第四级页表找到对应PPN。
图91 VA到PA的变换
7.5 三级Cache支持下的物理内存访问
三级Cache的结构如下图
图92 CPU结构图
根据物理地址PA,划分为CT标记,CI组索引,CO块内偏移,对L1进行访问,找到对应的组,对有效位为1的行进行标记位匹配,若匹配成功,则目标块在L1cache中,根据块内偏移取出数据。否则不命中,则在L2,L3cache中进行查找,若成功则采用最近最小替换策略,更新它的上级cache并取出数据。仍不命中,则在内存中查找。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为hello创建各种数据结构,并分配给它一个唯一的PID。为了给hello创建虚拟内存,它创建了当前进程的mm_struct,区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的体区域结构都标记为私有的写时复制。
当fork在新进程中返回时,hello现在的虚拟内存刚好和调用fork时存在的虚地内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
图92 私有的写时复制
7.7 hello进程execve时的内存映射
execve函数在内核级别执行,它负责在当前进程中加载并执行位于可执行目标文件hello中的程序,从而有效地用hello程序替换了当前进程中的程序。加载并运行hello程序涉及以下步骤:
删除已存在的用户区域:首先,execve会移除当前进程虚拟地址空间中用户部分的已存在区域结构,以准备加载新程序。
映射私有区域:为hello程序的代码、数据、未初始化数据(bss)和栈区域创建新的私有、写时复制的虚拟内存区域。代码和数据区域会被映射到hello文件的.text和.data部分,.bss 区域则通过请求二进制零来初始化,并映射到匿名文件,其大小由hello文件定义。同样地,栈和堆的初始地址也是通过请求二进制零来分配的,初始时长度为零。
映射共享区域:由于hello程序与共享对象链接,execve会将这些共享对象映射到用户虚拟地址空间的共享区域内,以确保它们能够被hello程序动态链接并使用。
设置程序计数器:最后,execve会设置当前进程上下文的程序计数器,使其指向新加载的hello程序代码区域的入口点,从而开始执行hello程序。
这些步骤确保了hello程序能够在当前进程的上下文中无缝替换先前的程序,并继续执行。
7.8 缺页故障与缺页中断处理
缺页故障是指在计算机操作系统的虚拟内存管理中,当程序访问从未加载到内存的新页,曾经加载过但被换出到磁盘的页,合法地址但当前不在内存的页时引发的中断或异常。此时,操作系统需要从外部存储(如硬盘)中将所需的页调入物理内存,以继续程序的执行。
处理流程如下:
第1步:处理器生成一个虚拟地址,并把它传送给MMU;
第2步:MMU生成PTE地址,并从高速缓存/主存请求得到它;
第3步:高速缓存/主存向MMU返回PTE;
第4步:PTE中的有效位是零,所以MMU触发了一次异常,传给CPU中的控制到操作系统内核中的缺页异常处理程序;
第5步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘;
第6步:缺页处理程序页面调入新的页面,并更新内存中的PTE;
第7步:缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,在MMU执行了图b中的步骤之后,主存就会将所请求字返回给处理器。
图93 缺页处理
7.9动态存储分配管理
hello中的printf调用了malloc
虽然可以使用低级的mmap和munmap函数来创建和删除虚拟内存区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器更方便,也有更好的可移植性。
动态内存分配器维护着一个进程的虚拟内存域,称为堆。对于每个进程,,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为 一组不同大小的块的集合来维护。分配器分为两种基本风格:显式分配器和隐式分配器。
显示分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块。
分配器的要求:必须处理任意请求序列;立即响应请求;只使用堆;对齐块;不修改已分配的块。
分配器的目标:吞吐率最大化;内存使用率最大化。
带边界标签的隐式空闲链表结构如下:
图94 带边界标签的隐式空闲链表
显示空闲链表结构如下:
图95 显示空闲链表结构
显示空闲链表通过直接链接空闲块来实现快速查找和合并,但需要额外的指针空间。而带标记的隐式空闲链表则通过内存块中的特定字段来隐含地表示空闲块之间的关系,减少了数据结构的开销,但可能需要更多的扫描操作来查找和合并空闲块。
适配块策略:首次适配倾向于提供较高的内存利用率;下一次适配则能实现较快的分配速度;而最佳适配有助于显著减少内存碎片的产生。当我们面对分离适配的场景时,通常选择首次适配作为策略,因为对于分离空闲链表而言,简单使用首次适配的策略,其内存利用效率接近于对整个堆进行最佳适配所能达到的效率。
7.10本章小结
通过本章,我们深入理解了虚拟内存及其对hello存储管理机制的重要性。虚拟内存不仅是对主存的抽象,还通过地址翻译(使用页表)支持处理器间接引用主存。它简化了内存保护和内存管理,并自动缓存磁盘上的虚拟地址空间内容。探讨了虚拟地址、线性地址、物理地址的关系,以及段式和页式管理。hello进程通过内存映射、缺页处理和动态内存分配等机制高效管理内存。这些概念共同构成了现代操作系统内存管理的核心。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备模型化:文件
设备管理:unix io接口
在Linux系统中,IO设备被抽象并模拟成文件的形式,从而所有对设备的输入和输出操作都被视为对相应文件的读写操作。这种将设备映射为文件的机制,为Linux内核提供了一种简洁、底层的应用接口,即Unix I/O。通过这种接口,系统能够以统一、一致的方式执行所有输入和输出操作,包括打开文件、调整文件指针位置、读取或写入文件内容,以及关闭文件。这种方式不仅简化了应用程序与设备之间的交互,还增强了系统的可移植性和灵活性。
8.2 简述Unix IO接口及其函数
Unix I/O 接口
1.打开文件:当程序需要访问一个文件时,它会请求内核打开该文件。内核随后会返回一个小的非负整数,即文件描述符,用于在后续的操作中唯一标识这个文件。程序只需记录这个描述符,就可以轻松地引用和管理已打开的文件。
2.Shell 在启动进程时会自动为其打开三个文件通道:标准输入、标准输出和标准错误。这三个文件通道分别用于进程接收输入、发送输出以及报告错误信息。
3.调整文件指针位置:对于每一个打开的文件,内核都会维护一个称为文件位置的变量 k,该变量初始化为 0,代表从文件开头的字节偏移量。应用程序可以通过执行 seek 操作来设置文件的当前读取或写入位置为任意值 k。
4.文件的读写操作:读取文件时,系统会从当前的文件位置 k 开始,将文件中的 n(n>0)个字节复制到内存中,并将文件位置 k 更新为 k+n。如果尝试从一个大小为 m 字节的文件中读取数据,且 k 已经大于等于 m(即已到达文件末尾),则会触发一个称为 EOF(End Of File)的条件,表示已经到达文件的结尾。应用程序可以检测到这个条件,并据此采取相应的操作。需要注意的是,文件末尾并没有一个显式的 EOF 符号来标识。
5.关闭文件:当文件不再需要时,程序会请求内核关闭该文件。内核会释放与打开文件相关的所有数据结构以及占用的内存资源,并将该文件的描述符释放回可用的描述符池中。此外,无论进程因何原因终止,内核都会自动关闭所有打开的文件,并释放它们的内存资源,以确保系统的稳定性和资源的高效利用。
Unix I/O 函数
1. 打开文件int open(char *filename, int flags, mode_t mode);
将 filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符,flags访问方式,O_RDONLY(只读),O_WRONLY(只写),O_RDWR(可读写),mode 参数指定了新文件的访问权限位。
2.关闭文件 int close(int fd);
关闭一个打开的文件。成功返回0,否则为-1。
3.读文件 ssize_t read(int fd, void *buf, size_t n);
从描述符为 fd 的当前文件位置赋值最多 n 个字节到内存位置 buf。返回值-1 表示一个错误,0 表示 EOF,否则返回读的字节数。
4.写文件 ssize_t write(int fd, const void *buf,size_t n);
从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件位置。返回写的字节数,出错则为-1。
5.移动文件描述符off_t lseek(int fd, off_t offset,int whence);
用于在指定的文件描述符中将将文件指针定位到相应位置。fd文件描述符。offset偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负。whence移动到的位置。成功返回当前位移,失败返回-1。
8.3 printf的实现分析
看查printf函数的函数体
图96 printf函数
va_list的定义:typedef char *va_list,它是一个字符指针。(char*)(&fmt) + 4) 表示的是...中的第一个参数。
再看vsprintf函数
图97 vsprintf函数
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。代码中的vsprintf只实现了对16进制的格式化。
再看write函数
图98 write函数
查看 write 函数的汇编实现,它首先给寄存器传递三个参数,然后执行 int INT_VECTOR_SYS_CALL,代表通过系统调用 sys_call
再看sys_call函数
图99 sys_call函数
sys_call将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,显示芯片按照刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar 函数的工作流程
函数调用:当程序执行到 getchar 函数调用时,它会暂停当前的操作,并将控制权交给操作系统(OS)。
等待输入:OS 监控着各种输入设备,包括键盘。当用户在键盘上键入字符时,这些字符的扫描码(对于原始输入)或ASCII码(对于经过处理的输入)会被发送到系统的键盘缓冲区。
回显:在许多终端和命令行界面中,当用户键入字符时,这些字符会立即在屏幕上显示(称为“回显”),给用户一个即时的反馈。这是由终端的“行缓冲”或“字符缓冲”功能实现的,这取决于终端的配置。
换行符(Enter键):当用户按下 Enter 键时,这通常被解释为一个特殊的信号,告诉系统用户已经完成了一个输入行。在大多数情况下,这会导致终端将整行的内容发送给程序。
读取输入:getchar 函数(或其底层的系统调用,如 Unix/Linux 上的 read)会从系统的键盘缓冲区中读取数据。这通常是一个阻塞调用,意味着它会等待直到有数据可用。一旦有数据(比如用户按下 Enter 键后),getchar 就会返回读取到的第一个字符的ASCII码,并将控制权交还给程序。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章讲述了Linux的I/O设备管理方法。介绍了Unix I/O接口及其函数,他们允许应用程序对文件进行操作,打开,关闭,读,写文件等。以及printf函数实现和getchar函数的实现,他们看似简单,低层实现其实也很复杂。
结论
Hello 经历过程的计算机系统语言总结:
预处理阶段:将 hello.c 源文件经过预处理器 cpp 处理,删除注释、展开头文件并进行宏替换,生成预处理后的 hello.i 文件。
编译阶段:编译器 cc1 将 hello.i 文件翻译为汇编语言,生成 hello.s 文件。在此过程中,编译器进行语法分析、语义分析并进行优化,生成高效的机器码。
汇编阶段:汇编器 as 将 hello.s 文件翻译为可重定位目标文件 hello.o,其中包含符号表、重定位表等元信息。
链接阶段:链接器 ld 将 hello.o 文件与所需的库文件链接,解析外部符号引用,生成最终的可执行文件 hello。
加载执行阶段:操作系统将 hello 加载到内存中,创建进程并为其分配虚拟地址空间。hello 进程启动,经历用户态到内核态的切换,并通过系统调用完成I/O操作、内存管理等功能。最后被shell回收。
对计算机系统设计与实现的感悟:
从hello的一生中,将简单的事情读厚,hello的出生只是几条简短的代码,背后的过程却历经重重困难,一步步成为可执行文件,在一步步的从磁盘加载到进程中,最后在屏幕上展示他光彩的一生。他的生命让我们体会到计算机系统设计背后超人的智慧,体会到计算机学科的魅力。通过编程和系统设计,我们可以将自己的想法和创意转化为实际的软件和系统,实现自己的价值和梦想。我想这种创造力和想象力是计算机学科独有的魅力所在,也是吸引无数人投身其中的原因之一。
创新理念:
计算机系统各个组件的协同工作是实现高效计算的关键。预处理、编译、汇编、链接、加载执行等环节环环相扣,任何一个环节的优化都会对整个系统的性能产生影响。未来的创新应当关注各个环节的协同优化,提高整体系统的吞吐率和资源利用率。
虚拟内存管理是现代操作系统的核心技术之一。通过页式管理和动态内存分配,操作系统为进程提供了统一的地址空间视图,极大地简化了程序员的编程工作。未来可以研究更智能的内存管理策略,如基于程序行为的预读机制、基于机器学习的页面置换算法等,以进一步提升内存系统的性能。
进程管理机制是操作系统实现并发计算的基础。通过进程切换、信号处理等机制,操作系统为应用程序提供了独立运行的环境。未来可以设计更加智能灵活的进程调度算法,充分利用现代多核处理器的计算能力,提高整体系统的并行计算效率。
输入输出管理是连接计算机系统与外部世界的关键。通过统一的文件抽象和系统调用接口,操作系统简化了应用程序的 I/O 编程。未来可以研究基于机器学习的 I/O 优化技术,根据应用程序的 I/O 模式自动调整缓存策略和预读机制,进一步提升 I/O 性能。
总之,通过 Hello 程序的深入分析,我对计算机系统的设计与实现有了更加深入的认识。未来的创新应当聚焦于各个关键组件的协同优化,充分利用现代硬件技术,提升整体系统的计算性能和资源利用率,为人类社会提供更加高效和智能的计算服务。
附件
文件名 | 功能 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | hello的ELF格式 |
hello.o.elf | hello.o的ELF格式 |
hello.asm | hello的反汇编文件 |
hello.o.asm | hello.o的反汇编文件 |
参考文献
[1] Randal E. Bryant, David R. O'Hallaon. 深入理解计算机系统. 第三版. 北京市:机械工业出版社[M]. 2018: 1-737
[2] 向前阿、. C语言从入门到实战——预处理详解.
https://blog.csdn.net/2203_75422717/article/details/137710666
[3] 知识分子_. 一文彻底搞懂字符串、字符串常量池原理.
https://blog.csdn.net/qq_45076180/article/details/115082348
[4] hahalidaxin. Linux下 可视化 反汇编工具 EDB
https://blog.csdn.net/hahalidaxin/article/details/84442132
[5] 闫晟. 逻辑地址、物理地址、虚拟地址.
https://blog.csdn.net/TYUTyansheng/article/details/108148566
[6] Pianistx. printf 函数实现的深入剖析.
https://www.cnblogs.com/pianist/p/3315801.html