摘要:
本文从hello的自白出发,从计算机系统的角度,在Ubuntu虚拟机中对hello程序从预处理、编译、汇编、链接到可执行文件,可执行文件hello的进程创建,存储管理,IO管理,信号处理,进程回收等过程进行了介绍。麻雀虽小五脏俱全,这不仅仅是hello程序的一生,从hello的示例,也让我们认识到了计算机是如何利用复杂而有序的层次结构实现众多强大的功能的,也让我们不禁感慨,前人的创造力和智慧。
关键词:计算机系统,程序执行,Hello。
第一章 概述
1.1Hello简介
首先编写hello.c
程序(Program),接着经过预处理程序,编译,汇编和链接生成可执行文件hello
实现了P2P(Program to Process)。
然后在壳(Bash)中执行hello
。操作系统里的进程资源管理器先为hello
创建一个子进程,再exceve将程序加载到内存中,使用mmap 系统调用将用户空间的虚拟内存地址与文件进行映射(绑定),此外,操作系统会分配时间片给进程,允许它在CPU、RAM和IO设备上执行指令,包括取指、译码、执行和流水线处理等操作。
接着操作系统的存储管理器和MMU实现虚拟地址(VA)到物理地址(PA)的转换,在TLB(快表),四级页表,三级cache,pagefile(页面交换文件)等等组件的配合下,加快程序执行速度; 通过I/O(设备管理)使得程序得以和键盘,主板,屏幕等硬件设备相互交互。
最后在程序执行完以后,由操作系统进行进程回收,hello
运行结束,实现了020(Zero to Zero)。
1.2环境和工具
1.2.1 硬件环境
处理器:12th Gen Intel® Core™ i5-12500H
RAM:16.00GB
1.2.2 系统类型
64位操作系统,基于x64的处理器
1.2.3 软件环境
Windows11 64位;Ubuntu 20.04
1.2.4 开发与调试工具:
gcc,as,ld,edb,readelf
1.3中间结果
文件名 | 文件作用 |
---|---|
hello.i | 预处理后的文件 |
hello.s | 编译后文件 |
hello.o | gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o |
汇编后可重定位目标文件 | |
hello | ld链接后可执行文件 |
elf_hello_o.txt | readelf -a hello.o |
hello.o ELF 格式 | |
elf_hello.txt | readelf -a hello |
hello ELF 格式 | |
dump_hello_o.txt | objdump -d -r hello.o |
hello.o反汇编 | |
dump_hello.txt | objdump -d -r hello |
hello反汇编 |
1.4本章小结
本章利用计算机术语,重新复述了Hello的P2P,020的整个过程。说明了大作业所使用的环境和工具,并列出了所有中间文件。
第二章 预处理
2.1预处理的概念与作用
2.1.1预处理概念:
- C语言 提供了多种预处理功能,如宏定义、文件包含、条件编译等。 以" # "号开头的预处理命令:包含命令 #include ,宏定义命令 #define 等。 在源程序中这些命令都放在函数之外,而且一般都放在源文件的前面,它们称为预处理部分
- 所谓预处理是指在进行编译的第一遍扫描(词法扫描和语法分析)之前所作的工作。预处理是C语言的一个重要功能,它由预处理程序负责完成。当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译
2.1.2预处理作用:
c语言提供的编译预处理功能主要有三种:宏定义、文件包含和条件编译。
宏定义:
在 C 语言源程序中允许用一个标识符来表示一个字符串,称为“宏”。被定义为“宏”的标识符称为“宏名”。在编译预处理时,对程序中所有出现的宏名,都用宏定义中的字符串去代换,这称为“宏代换”或“宏展开”。宏定义是由源程序中的宏定义命令完成的,宏代换则是由预处理程序自动完成的。
文件包含:
文件包含命令的一般形式为
#include"文件名" 或 #in2clude<文件名>
文件包含命令的功能是把指定的文件插入谈该命令行位置取代该命令行,从而把指定的文件和当前的源程序文件连成一个源文件。因为有些公用的符号常量或宏定义等可单单独组成一个文件,在其他文件的开头用包含命令包含该文件即可使用。这样,可避免在每个文件开头都去书写那些公用量,从而节省时间,并减少出错。
条件编译
预处理程序提供了条件编译的功能。可以按不同的条件去编译不同的程序部分,因而产生不同的目标代码文件。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
2.3Hello的预处理结果解析
预处理生成的hello.i
文件远大于hello.c
文件,打开hello.i
,可以看见多了很多的代码,这些都是头文件中引用的文件。
打开hello.i
文件,可以看到,程序中插入了头文件中引用的程序,并且原来的hello.c
文件在最后面.
因为预处理只是对# 的一些语句进行处理,因为程序有文件包含,所以预处理程序将stdio.h,unistd.h,stdlib.h
中的内容加到hello.i
中,并且如果这些文件中还存在宏,继续进行展开,直到最终的hello.i文件中没有宏定义、文件包含及条件解析等内容。同时可以发现,最后的源程序并没有多大的变化,因为源程序中没有用到宏定义和条件编译。
2.4本章小结
通过对预处理的概念和作用的阐述,详尽的展示了预处理这一步骤,并通过Ubantu下的预处理指令,对hello.i
预处理文件进行了解析。
第三章 编译
3.1编译的概念与作用
3.1.1编译的概念:
整个编译过程,就是将高级语言翻译为机器语言的过程:通常将其分为6步,包括扫描、词法分析、语义分析、源代码优化、代码生成、目标代码优化。
3.1.2编译的作用:
把源程序翻译成机器可以识别的指令集.
3.2在Ubuntu下编译的命令
gcc -m64 -S hello.i -o hello.s
3.3Hello的编译结果解析
3.3.1 .s文件结构详解
内容 | 含义 |
---|---|
.file | 指定源文件名称 |
.text | 声明一个名为.text的代码段 |
.section .rodata | 声明一个名为.rodata的段,通常用于存放只读数据 |
.align | 指示数据在内存中的对齐方式 |
.LC0 | 这是一个标签(label),用于标识一个字符串常量的位置。 |
.string | 指定了一个字符串常量的内容 |
.LFB6 | 表示一个局部函数块的开始 |
.cfi_startproc | 指示调试信息的开始 |
endbr64 | 指示函数的结尾 |
.size | 指定大小 |
所有以’.'开头的行都是直到汇编器和链接器工作的伪指令。我们通常可以忽略这些行。
文件对比hello.i
小了非常多。 .s文件包含了汇编代码、字符串常量的定义、函数的实现等内容。
3.3.2 汇编语句详解:
数据格式
C声明 | Intel 数据类型 | 汇编代码后缀 | 大小(字节) |
---|---|---|---|
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l | 4 |
long | 四字 | q | 8 |
char* | 四字 | q | 8 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
访问信息
寄存器以%r
开头.
操作数指示符
立即数:
使用$ + 整数
表示常数值
$32表示常数32
寄存器:
它表示某个寄存器的内容
%rdx表示寄存器rdx
内存引用:
-32(%rbp)是指rbp中地址减去32后得到的地址中的值
数据传送指令:
mov
+ b/w/l/q分别代表传送1/2/4/8个字节
movl %edi, -20(%rbp),表示把edi寄存器中的值复制到rbp存的地址-32的地址里面
压入和弹出数据:
pushq
和popq
分别代表将8字节压入栈和弹出栈
算数和逻辑操作:
指令 | 效果 | 描述 |
---|---|---|
leaq S,D | D <- &S | 加载有效地址 |
INC D | D <- D + 1 | 加1 |
DEC D | D <- D - 1 | 减1 |
NEG D | D <- -D | 取反 |
NOT D | D <- ~D | 取补* |
ADD S,D | D <- D + S | 加 |
ADD S,D | D <- D - S | 减 |
ADD S,D | D <- D * S | 乘 |
ADD S,D | D <- D ^ S | 异或 |
ADD S,D | D <- D | S | 或 |
ADD S,D | D <- D & S | 与 |
SAL k,D | D <- D <<k | 左移 |
SHL k,D | D <- D >>k | 右移 |
SHL k,D | D <- D>>Ak | 算数右移 |
SHR k,D | D <- D>>Lk | 逻辑右移 |
只有中间一列用的比较多
addq把立即数8,加上%rax中的值,放入%rax
控制
条件码:
CF(carry flag): 进位标志.最近的操作使最高位产生了进位或借位. 可用来检查无符号操作数的溢出,最高位1 + 1 了或0 - 1
ZF:零标志.最近的操作得出的结果为0,则ZF=1.
SF(sign flag):符号标志.最近的操作得到的结果为负数.
OF(overflow flag):溢出标志.最近的操作导致一个补码溢出–正溢出或负溢出.正数 + 正数反而最高位为1或者负数 + 负数反而最高位为0
PF(parity flag):最低字节中的1的个数为0个或者偶数个时,PF = 1,否则为0.
AF(Auxiliary carry Flag)辅助进位标志,记录运算时第3位(半个字节)产生的进位置。
有进位时1,否则置0.
DF(Direction Flag)方向标志,在串处理指令中控制信息的方向。
IF(Interrupt Flag)中断标志。
TF(Trap Flag)陷井标志。
指令 | 基于 | 描述 |
---|---|---|
CMP S1,S2 | S2 - S1 | 比较 |
cmpb | ||
cmpw | ||
cmpl | ||
cmpq | ||
TEST S1,S2 | S1 & S2 | 测试 |
testb | ||
testw | ||
testl | ||
testq |
ATT格式中列出操作数的顺序是反的
cmp和test只修改标志位,不修改操作数
cmp %eax,%ebx 相等ZF位置1(cmp就是减法,减法为0,ZF=1,说明相等)
可用testq %rax,%rax,来判断%rax使负数(0)或者正数,(如果为0,说明%rax自己和自己相与得0,ZF = 1)
跳转指令:
指令 | 同义名 | 跳转条件 | 描述 |
---|---|---|---|
jmp Lable(标签) | 1 | 直接跳转 | |
jmp *Operand | 1 | 间接跳转 | |
je Lable | jz | ZF | 等于/0 equal |
jne Lable | jnz | 不相等/非0 | |
js Lable | 负数 | ||
jns Lable | 非负数 | ||
jg Lable | jnle | 大于(有符号>)giant | |
jge Lable | jnl | 大于等于(有符号>=) | |
jl Lable | jnge | 小于(有符号<)less | |
jle Lable | jng | 小于等于(有符号<=) | |
ja Lable | jnbe | 超过(无符号>) above | |
jae Lable | jnb | 超过或相等(无符号>=) | |
jb Lable | jnae | 低于(无符号<)below | |
jbe Lable | jna | 低于或相等(无符号<=) |
比较立即数4和-20(%rbp),如果相等就跳转到L2(一个标签)
循环:
可以看出这段汇编语言对应源程序的for循环
3.4本章小结
本章介绍了汇编的概念和作用,对hello.s
汇编文件的结构进行了分析。详细介绍了汇编语言中各种语句的功能,对汇编语言有了更深刻的认识。
第四章 汇编
4.1汇编的概念与作用
4.1.1汇编的概念:
汇编器(as)将汇编程序(XX.s)翻译成机器语言指令,把这些指令打包在文件XX.o中
4.1.2汇编的作用:
利用汇编器将汇编语言翻译成机器可以识别的机器语言指令(二进制)
4.2在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
4.3可重定位目标ELF格式
详细结构介绍:
4.3.1 ELF头:
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字大小和字节顺序
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字大小和字节顺序
第1-4个字节:
elf格式的魔数就是7f “e” “l” “f”,对应 7f454c46。
这样系统就知道我是elf格式的文件了。
第5个字节是表示几位的 32位还是64位,01是32位
第6个字节是字节序,01是小端字节序
第7个字节是elf版本,一般都是01
后面9个是附加信息
4.3.2 .symbtab:
一个符号表,它存放着程序中定义和引用的函数和全局变量的信息。由于还没有重定位,所以Value都为0
4.3.3 .rela.text:
一个.text节中位置的列表,当链接器把这个目标文件和其他目标文件组合时,需要修改这些位置(告诉链接器哪些地方需要重定位),一般而言任何调用外部函数或者引用全局变量的指令都需要修改
.rel节和.rela节的区别在于它们保存重定位信息的方式不同。在.rel节中,重定位信息保存为对应节中的偏移量和符号表索引;而在.rela节中,重定位信息保存为对应节中的偏移量、符号表索引以及符号的类型等信息。
这些信息描述了在链接时需要重定位的符号:.rodata、puts、exit、printf、atoi、sleep和getchar。
而且还涉及不同类型的重定位,如PC相对寻址和PLT相对寻址。
4.3.4 其他的节:
.text:已编译的程序机器代码
.rodata:只读数据,如printf中的格式串和开关语句中的跳转表
.data:以初始化的全局和静态变量
.bbs:未初始化的全局和静态变量,或是初始化为0的全局和静态变量
.rel.data:被模块引用或定义的所有全局变量的重定位信息。
.debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C 源文件。只有以-g为选项编译才有
.line:原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g为选项编译才有
.shstrtab:一个字符串表。其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。
4.4Hello.o的结果解析
**构成:**机器语言主要包括指令地址,操作码和操作数。图中看到的后面的是注释部分。
**映射关系:**汇编语言中的语句会被映射成指令,这些指令翻译后变成计算机能执行的二进制机器语言
因为还没有经过链接,所以main的地址是0000000000000000,每一段的地址都是这个值,这个地址会在链接阶段更新。
还可以看到夹在指令之间的类似‘64: R_X86_64_PLT32 printf-0x4’,这些语句是可重定位条目,可以看到,这些语句上面对应的call语句前面的16进制编码是不完整的,是用相对偏移暂时填充的,会在链接阶段重定位、修改。
与hello.s对比
可以发现,汇编语言中的操作数是10进制的,而机器语言中的操作数是16进制的
可以看到,hello.o
文件中不再显示.L1等段,函数调用的目标地址是当前指令的下一条指令的地址。因为要调用函数的地址目前是不确定的,需要在链接阶段进行重定位。
hello.o
文件还多了前面的序号,也会在链接阶段进行更新。
4.5本章小结
本章我们分析了汇编的概念和作用,通过对可重定位目标文件ELF结构的解析,了解了可重定位目标文件的构成。为了进一步了解hello.o
文件的组成,我们还使用反汇编查看了hello.o
文件的内容,并且与hello.s
进行了对比,同时也进一步认识到了链接的作用。
第五章 链接
5.1 链接的概念与作用
5.1.1链接的概念:
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。
5.1.2链接的作用:
链接器使分离编译成为可能,我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。
5.2 在Ubuntu下链接的命令
5.2.1 gcc
gcc 命令是一个包含了编译和链接功能的高级命令。它会自动调用编译器和链接器,并且会自动添加所需的库文件和启动文件。因此,gcc hello.o -o hello 只需要指定目标文件和生成的可执行文件名,就可以完成编译和链接过程。
gcc hello.o -o hello
5.2.2 ld
ld 命令是专门用于链接的命令。它需要手动指定需要链接的目标文件和库文件,以及一些其他的链接选项。因此,ld 命令的命令行会显得非常长,并且需要手动处理很多细节。
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
- -o hello:这部分指定了链接后生成的可执行文件的名称为 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、/usr/lib/x86_64-linux-gnu/crtn.o:这些是启动文件,包含了程序的启动和结束代码,以及一些初始化工作。
- hello.o:这是你自己编译的目标文件,它包含了你的程序的代码和数据。
- /usr/lib/x86_64-linux-gnu/libc.so:这是C标准库的共享对象文件,包含了C语言标准库的实现。
5.3 可执行目标文件hello的格式
可执行文件格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式。它还包括程序的入口点,也就是程序运行时第一条指令的地址。.test,.rodata,.data节与可重定位目标文件中的节是相似的,除了这些节已经被重定位到了它们最终的运行地址。.init节定义了一个小函数,叫做._init,程序的初始化代码会调用它。因为可执行文件是完全链接的(以重定位的),所以它不再需要rel节。
可以使用readelf -a hello
查看可执行目标文件hello
的结构。
5.3.1 ELF头
hello的ELF头和hello.o的ELF头相比类型改变了,更新了入口点地址和程序头起点。
5.3.2 节头
readelf -S hello
包含了 ELF 文件中各个段(Section)的信息,如段的名称、偏移、大小等。
在 ELF 文件的段头部表(Section Header Table)中,每一行对应一个节(Section),列出了该节的各种属性。下面是各列的含义:
- [号]:节的索引号,用于标识该节在段头部表中的位置。
- 名称:节的名称,描述了该节的作用或内容,比如
.text
表示代码段,.data
表示数据段等。 - 类型:描述了该节的类型,比如代码段、数据段、符号表等。
- 地址:节在内存中的虚拟地址,即在程序运行时被加载到内存中的地址。
- 偏移量:该节在文件中的偏移量,即该节在文件中的起始位置距禫文件头的字节偏移量。
- 大小:该节在内存中的大小,即该节占用的内存空间大小。
- 全体大小:该节在文件中的大小,即该节在文件中占用的字节数。
- 旗标:描述了该节的属性,比如可执行、可写、可读等。
- 链接:对于某些特殊类型的节,这个字段可能表示其他相关节的索引。
- 信息:对于某些特殊类型的节,这个字段可能包含一些特定的信息。
- 对齐:描述了节在内存中的对齐方式,即节在内存中的起始地址需要满足的对齐要求。
5.3.3 程序头:
程序头表描述了可执行文件在内存中的布局,包括了各个段在内存中的加载地址、大小等信息。
5.3.4 .symtab
符号表包含了程序中定义的符号(如变量、函数名)及其地址等信息
5.3.5 动态段Dynamic Section
动态段包含了在程序运行时需要动态链接的信息,比如共享库的依赖、重定位表等。
除此之外还有一些结构,就不一一介绍了。
5.4 hello的虚拟地址空间
使用edb的Memory Regions查看hello的虚拟地址空间
- 第一列表示该段在进程的虚拟地址空间中的起始地址。
- 第二列是段终止的虚拟地址。
- 第三列包含了段的权限信息,包括 r(可读)、w(可写)、x(可执行)等。
- 第四列包含了段的名称或者描述信息。
从这段信息中,我们可以看到进程的虚拟地址空间中包含了一些文件映射(如 hello 可执行文件和共享库 libc.so.6),以及一些匿名映射([ anon ])和栈空间([ stack ])。每个条目都描述了进程虚拟地址空间中的一个段的信息,包括它的起始地址、大小和权限等。
0x400000 - 0x405000包括了hello的.text,.data,.bss。0x7f2898cd6000 - 0x7f2898d12000包括共享库。stack对应的就是用户栈。
5.5 链接的重定位过程分析
符号解析下一步是重定位
5.5.1重定位节和符号定义:
这一步,链接器将所有相同类型的节合并为同一类型的聚合节。例如,来自所有输入模块的.data节全部被合并成为一个节,这个节成为输出可执行文件的.data节。然后链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
5.5.2重定位节中的符号引用:
在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。要执行这一步,链接器依赖可重定位目标模块中成为重定位条目的数据结构。
5.5.3hello.o和hello不同点:
1.hello.o最前面是指令之间的偏移,而hello最前面已经替换成了虚拟地址
2.hello中的函数调用已经有了确定的地址,而hello.o中的函数调用处有一个重定位条目,具体的地址为空
hello.o夹在指令之间的类似‘64: R_X86_64_PLT32 printf-0x4’,这些语句是可重定位条目,可以看到,这些语句上面对应的call语句前面的编码是不完整的,是用相对偏移暂时填充的。
3.hello中很多地址指向了一些共享库里的函数,当hello调用虚拟地址处的函数后,虚拟地址处的地址翻译后就是共享库中函数的地址。hello.o中不体现这些函数调用,函数调用的就是下一句指令。
于是重定位合并了输入模块,并为每个符号分配了运行时的地址。
5.5.4 重定位方法:
hello进行链接,会先按5.5.1,5.5.2执行一些静态链接,而puts,exit,printf执行动态链接,不需要把这些代码添加到hello文件中,而是当加载器加载和运行可执行文件hello时,它利用exceve调用加载器,加载部分链接的可执行文件hello。接着,它注意到hello包含一个.interp节,这一节包含动态链接器的路径名,动态链接器本身就是一个共享目标(如在Linux系统上的ld-linux.so)。
hello用到的共享库为libc.so
加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器。然后,动态链接器通过执行下面的重定位完成链接任务:
●重定位libc.so的文本和数据到某个内存段。
●重定位hello中所有对由libc.so定义的符号的引用。
最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。
5.6 hello的执行流程
使用gdb调试hello,并使用rbreak在每个函数调用处打上断点
5.7 Hello的动态链接分析
5.7.1地址无关代码:
地址无关代码主要是针对于模块间的数据访问和函数调用,模块内的数据或者函数可以使用相对地址来访问到,这样也是地址无关的。那么模块间的数据或者函数调用,使用GOT这个结构来实现。GOT即为全局偏移表,GOT存放了使用的每个数据或者函数的最终地址,GOT可以理解成是一个以4字节(32位下)为一个子项的数组。GOT不会被多个进程共享,每个进程都有一份GOT。这样当代码中想要使用变量或者函数调用,会先找到GOT中,进而再从GOT中找到相应的函数或者变量进行访问。代码找GOT这个操作是相对寻址,所以可以被共享。GOT是在程序的装载时被修改。
5.7.2延迟绑定:
当开启了PIC时,默认就有延迟绑定的功能。也就是说在函数第一次调用的时候才进行绑定,所谓的绑定就是符号查找,重定位GOT或者数据段等。而不是加载的时候就一股脑的将所有的符号全部重定位完成。elf是通过PLT(Procedure Linkage Table)的方法来实现的。简单的原理就是会有一个plt的段,当进行模块间的函数调用时,代码段中的调用都是先到plt段中,plt中会继续调用dl_runtime_resolve函数进行符号的解析和重定位进一步到got中的地址,当函数第二次调用到plt段中就能直接找到相应got中的地址实现跳转。
PLT表发生了变化。
在动态链接前,GOT 表中的条目通常包含对 PLT 表中条目的引用,而 PLT 表中的条目包含了跳转指令,用于调用动态链接库中的函数。这些条目在动态链接前通常被填充为一些占位符或默认值,因为动态链接器还没有解析和填充这些表的具体地址。
在动态链接后,当程序首次调用动态链接库中的函数时,动态链接器会将这些表中的条目填充为实际的地址。这些地址指向了动态链接库中的函数或变量的实际位置。这样,当程序执行时,它会通过这些表来获取外部函数或者变量的地址,从而实现动态链接的功能。
5.8 本章小结
本章首先介绍了链接的概念和作用。然后在Ubantu下进行链接操作,得到可执行文件hello
,再通过readelf -a hello
对hello
的结构进行了分析。通过进程管理pmap pid
对hello
运行时的虚拟地址进行了查看。然后介绍了重定位的过程。通过gdb对函数调用打断点查出了hello
的执行流程。最后对hello
动态链接的过程进行了分析。通过本章的梳理,加深了我们对链接的认识,熟悉了可执行文件的结构,理解了动态库的重要性。
第六章 HELLO进程管理
6.1 进程的概念与作用
6.1.1 进程的概念:
进程是一个正在运行的程序的实例,系统中的每一个程序都运行在某个进程的上下文中。
6.1.2 进程的作用:
- 实现并发执行:进程可以让多个程序在同一时间内并发执行,从而提高计算机系统的利用率。
- 实现多任务处理:操作系统可以同时运行多个进程,每个进程执行一个特定的任务,从而实现多任务处理。
- 管理系统资源:进程是操作系统资源分配的基本单位,操作系统通过进程来管理系统资源,如内存、CPU 时间、I/O 设备等。
- 实现程序的隔离:进程可以实现程序之间的隔离,每个进程都有独立的内存空间和运行环境,可以避免不同程序之间的相互影响和干扰。
- 提高系统可靠性:通过进程的隔离和管理,操作系统可以提高系统的可靠性和稳定性,避免进程之间的错误和崩溃对整个系统造成影响。
- 实现系统安全性:进程的隔离和管理也可以帮助操作系统实现系统的安全性,比如控制进程的权限、防止恶意进程等。
6.2 简述壳SHELL-BASH的作用与处理流程
6.2.1 作用:
Shell ,bash是一种命令行解释器,它允许用户与操作系统进行交互,执行命令、管理文件、运行程序等。
6.2.2 处理流程:
- **提示符:**当你打开一个终端窗口时,Shell 通常会显示一个提示符,等待用户输入命令。
- **命令解析:**当用户输入命令并按下回车键时,Shell 会对输入的命令进行解析,识别命令名称、参数、重定向符号等。
- **命令执行:**一旦命令解析完成,Shell 将根据解析结果执行相应的操作。这可能包括执行系统内置命令、调用外部程序、执行脚本文件等。
- **进程创建:**在执行命令时,Shell 可能会创建新的进程来运行命令所对应的程序。这个新进程会继承 Shell 进程的环境变量和文件描述符等信息。
- **命令输出:**当命令执行完毕后,Shell 会将命令的输出(如果有的话)显示在终端窗口上,或者根据重定向符号将输出重定向到指定的文件中。
- **等待下一条命令:**一条命令执行完毕后,Shell 会再次显示提示符,等待用户输入下一条命令。
6.3 HELLO的FORK进程创建过程
当在命令行输入./hello 2022111225 liuxinyu 3
之后,shell识别到这不是内置命令,于是创建一个新的子进程,这个子进程会获得和父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本。父进程与新创建的子进程最大的区别就是有不同的PID。
6.4 HELLO的EXECVE过程
exceve函数:
int execve(const char *filename,const charargv[],const char envp[]
exceve函数会在当前进程的上下文中加载并运行一个新程序filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,exceve才会返回到调用程序。
在执行execve函数时,当前进程的虚拟内存段被删除,并被新程序的代码、数据和堆所替代。栈和堆会被初始化为0,代码段和数据段则会被初始化为新程序可执行文件中的内容。最后,程序计数器(PC)被设置为新程序的入口点(通常为_start的地址)。
在hello
创建的子进程中,会紧接着执行exceve函数,执行hello
程序。
6.5 HELLO的进程执行
6.5.1 用户模式和内核模式
内核模式(Kernel Mode):
用户程序调用系统 API 函数称为系统调用(System Call);发生系统调用时会暂停用户程序,转而执行内核代码(内核也是程序),访问内核空间,这称为内核模式(Kernel Mode)。
任务可以执行特权级指令,对任何I/O设备有全部的访问权,还能够访问任何虚地址和控制虚拟内存硬件。
用户模式**(User Mode)**:
用户空间保存的是应用程序的代码和数据,是程序私有的,其他程序一般无法访问。当执行应用程序自己的代码时,称为用户模式(User Mode)。
硬件防止特权指令的执行,并对内存和I/O空间的访问操作进行检查,可以通过操作系统中的某种门机制进入内核模式访问。
6.5.2 上下文切换:
内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。
6.5.3进程调度:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并上下文切换来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
6.5.4 进程执行:
当hello进程执行到sleep时,这个进程会显示地请求让调用进程休眠,并使CPU执行另外一个进程。同时hello进程开始计时,当计时结束,sleep函数返回,触发一个中断,使得hello进程重新被调度,继续运行。
6.6 HELLO的异常与信号处理
6.6.1 bg将前台作业调度到后台执行:
因为hello在执行时输入的字符无效,所以这条命令无法执行
6.6.2 fg将后台作业调度到前台执行:
6.6.3 jobs列出所有作业:
包括jid, 进程状态和进程信息
6.6.4 ps列出所有进程:
6.6.5 kill杀死进程:
使用kill -9 hello
命令给hello进程发送终止信号
6.6.6 ctrl-c结束前台进程:
6.6.7 ctrl-z挂起前台进程:
可用fg将进程再次开启
6.7本章小结
本章首先介绍了进程的概念和作用,理解了进程对于系统实现复杂的功能的重要性。接着介绍了Shell-Bash的作用和处理流程,知晓了shell作为命令解释器,帮助用户和操作系统进行交互的作用。剩下的操作都是在bash中执行的。然后详细分析hello的fork进程创建过程和exceve程序加载过程。通过对进程的介绍分析了hello的进程执行流程。最后测试了hello进程的异常和信号处理
第七章 HELLO的存储管理
7.1 HELLO的存储器地址空间
7.1.1 存储地址空间
是指对存储器编码(编码地址)的范围。所谓编码就是对每一个物理存储单元(一个字节)分配一个号码,通常叫作“编址”。分配一个号码给一个存储单元的目的是为了便于找到它,完成数据的读写,这就是所谓的“寻址”(所以,有人也把地址空间称为寻址空间)。
CPU在操控物理存储器的时候,把物理存储器都当作内存来对待,把它们总的看作一个由若干存储单元组成的逻辑存储器,这个逻辑存储器就是我们所说的内存地址空间。
有的物理存储器被看作一个由若干存储单元组成的逻辑存储器,每个物理存储器在这个逻辑存储器中占有一个地址段,即一段地址空间。CPU在这段地址空间中读写数据,实际上就是在相对应的物理存储器中读写数据。
7.1.2 逻辑地址:
逻辑地址是指程序中使用的地址,这些地址是相对于程序自身的地址空间而言的,程序在执行时使用的地址就是逻辑地址。在多道程序设计中,每个程序都有自己的逻辑地址空间,这使得每个程序都可以认为自己是独占整个内存空间的。
7.1.3 线性地址:
线性地址是指经过分段机制或分页机制转换之后,得到的地址。在分段机制中,逻辑地址首先被转换成线性地址,然后再转换成物理地址;在分页机制中,逻辑地址首先被转换成虚拟地址(也叫线性地址),然后再转换成物理地址。线性地址空间是一个连续的地址空间,方便程序的编写和管理。
7.1.4 虚拟地址:
虚拟地址是指程序中使用的地址,它是相对于虚拟内存而言的,虚拟内存是操作系统为每个进程分配的一部分内存空间。程序中所有的地址都是虚拟地址,操作系统会负责将虚拟地址转换成物理地址。
7.1.5 物理地址:
物理地址是指内存条上的真实地址,也就是硬件上真正存在的地址。当程序需要访问内存中的数据时,虚拟地址需要经过地址转换,最终转换成物理地址,才能找到数据的真实位置。
7.2 INTEL逻辑地址到线性地址的变换-段式管理
段式管理:逻辑地址->线性地址==虚拟地址
7.2.1 基本思想:
- 程序按内容或过程(函数)关系分成段,每段有自己的名字。一个用户作业或进程所包含的段对应于一个二维线性虚拟空间,也就是一个二维虚拟存储器。
- 段式管理程序以段为单位分配内存,然后通过地址映射机构把段式虚拟地址转换成实际的内存物理地址。
7.2.2 段寄存器:
① 在保护模式下,段寄存器的唯一目的就是存放段选择符
② X86架构共6个段寄存器,cs / ss / ds / es / fs / gs,其中3个有专门用途,
cs:代码段寄存器,指向包含程序指令的段
ss:栈段寄存器,指向包含当前程序栈的段
ds:数据段寄存器,指向包含静态数据或全局数据的段
说明:RPL和CPL
① 段选择符的最低2位为RPL,即请求者特权级
② 将段选择符加载到cs寄存器后,cs寄存器的最后2位就用于指明CPU的当前特权级,即CPL(Cuurent Privilege Level)
7.2.3 段描述符:
① 每个段由一个8B的段描述符(Segment Descriptor)表示,他描述了段的特征
② 段描述符存放在全局描述符表(Global Descriptor Table,GDT)或局部描述符表(Local Descriptor Table,LDT)中
③ 通常只定义一个GDT,而每个进程除了存放在GDT中的段之外,如果需要创建附加的段,就可以有自己的LDT
④ GDT在内存中的地址和大小存放在gdtr控制寄存器中;当前正在被使用的LDT在内存中的地址和大小存放在ldtr控制寄存器中
⑤ 段选择符中的索引号(高13位),就是用于在描述符表中索引描述符
说明1:GDT的第一项总是设置为0,以确保空段选择符的逻辑地址会被认为是无效的,因此引起一个处理器异常
说明2:能够保存在GDT中的段描述符的最大数量是8192个,即2^13 - 1
在存储器寻址的过程中,处理器会首先从段寄存器中选出相应的段选择符,然后根据这个段选择符在 GDT 或 LDT 中找到对应的段描述符。接下来,处理器会使用段描述符中的信息获得段基址。加上偏移量得到线性地址。
7.3 HELLO的线性地址到物理地址的变换-页式管理
7.3.1 虚拟页:
VM系统通过将虚拟内存分割为称为虚拟页(Virtual Page)的大小固定的块作为磁盘和主存之间的传输单元。虚拟页往往4KB~2MB。`
在任何时刻,虚拟页面的集合都分为三个不相交的子集:
未分配的:VM系统还未分配(或者创建)的页。不占据任何磁盘空间
缓存的:当前已缓存在物理内存中的已分配页
为缓存的:未缓存在物理内存中的已分配页
7.3.2 页表:
概念:
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM中,替换这个牺牲页。
这些功能是由软硬件联合提供的,包括操作系统软件、MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存中叫做页表(page table)的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。
每一个PTE条目包括一个有效位和物理页号(PPN)
7.3.3 变换流程:
对于一个虚拟地址(VA),分为虚拟页号(VPN)和虚拟页偏移量(VPO)两部分。虚拟页号(VPN)类似于页表条目的索引,通过VPN找到对应的PTE。
当PTE有效位为1时,表示页命中,把PTE中的物理页号(PPN)和虚拟地址的虚拟页偏移量(VPO)组合得到物理地址。
当PTE有效位为0时,如果物理页号为空,报错,非法访问。如果物理页号不为空,则是虚拟页未被缓存,触发一个缺页异常,系统调用内核中的缺页异常处理程序,选择一个牺牲页,将对应的虚拟页缓存到物理内存并修改对应的PTE条目。
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1 TLB:
正如我们看到的,每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会要求从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就下降到1个或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为编译后备缓冲器(Translation Lookaside Buffer,TLB)。
利用TLB示例:
图a展示了当TLB命中时(通常情况)所包括的步骤。这里的关键点是,所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。
●第1步:CPU产生一个虚拟地址。
。第2步和第3步:MMU从TLB中取出相应的PTE。
●第4步:MU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
●第5步:高速缓存/主存将所请求的数据字返回给CPU。
当TLB不命中时,MMU必须从L1缓存中取出相应的PTE,如图b所示。新取出的PTE存放在TLB中,可能会覆盖一个已经存在的条目。
7.4.2 多级页表:
通过使用层次结构的页表达到压缩页表的目的,减少页表驻留内存的空间。
7.4.3 TLB与四级页表下VA到PA的变换:
现在的Core i7实现支持48位(256TB)虚拟地址空间和52位(4PB)物理地址间。
在CPU收到的48位VA,前36位是虚拟页号VPN,后12位是物理地址的偏移VPO。VPN首先去TLB中,通过后四位TLBI确定TLB的组号,前32位作为索引找到对应的页,如果找到了,就与VPO组成52位的物理地址PA
如果TLB中没有命中,就通过四级页表去找PTE,每九位代表了一个片,每个片被用作到一个页表的偏移量。直到查到第四级页表找到对应的虚拟页的PTE,这个PTE包含了PPN,与PPO组合后得到物理地址PA。同时MMU会把这个条目添加到TLB中。
7.5 三级CACHE支持下的物理内存访问
见上图,在找到物理地址之后,通过CI组索引找到组号,通过CT(前40位)标记位找到对应的块,如果valid位为1,命中,通过CO(后6位)确定块偏移获取数据并返回给CPU。
如果valid位不为1或者没有找到对应的块,不命中,这时需要向L2,L3,主存一级一级往下找,直至找到。同时需要将所在块写到上一级存储结构中。
7.6 HELLO进程FORK时的内存映射
fork函数被当前进程调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了当前进程的mm struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 HELLO进程EXECVE时的内存映射
execve(const char *filename,const charargv[],const char envp[]
exceve在当前进程中加载并运行程序filename
,需要以下几个步骤:
●删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
●映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。
●映射共享区域。如果a.out程序与共享对象(或目标)链接,比如标准C库libc.
So,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
●设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换人代码和数据页面。
7.8 缺页故障与缺页中断处理
假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
缺页异常有三种情况:
7.8.1 虚拟地址A不合法:
A处在未进行定义的区域里,缺页处理程序会触发一个段错误终止这个进程。
7.8.2 内存访问不合法:
进程对A地址这个页面没有读写或者执行这个区域内页面的权力。
7.8.3 对合法的虚拟地址进行合法的访问:
选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。
7.9动态存储分配管理
就介绍一下概念:
7.9.1概念:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(读做“break”),它指向堆的顶部。
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
7.10本章小结
本章堆hello的存储管理做了详尽的介绍。首先介绍了逻辑物理虚拟线性存储地址的概念和区别。再介绍了Intel从逻辑地址转到线性地址的过程。接下来是从线性地址到物理地址的过程。随后以Core i7为例,介绍了在TLB和四级页表下的虚拟地址向物理地址的转换过程,和利用三级Cache从物理地址到目标的访问过程。
又介绍了fork,exceve函数的内存映射,Linux下的缺页处理和动态链接。
第八章 HELLO的IO管理
8.1 LINUX的IO设备管理方法
所有的I/O设备(例如网络、磁盘和终端)被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述UNIX IO接口及其函数
UNIX I/O 函数是 UNIX 操作系统提供的一组用于进行输入输出操作的函数,它们可以被用于对文件、设备和网络连接等资源进行读写操作。下面是一些常用的 UNIX I/O 函数及其简要描述:
- open():打开文件或者创建文件,返回文件描述符,可以指定文件的打开模式和权限。函数原型为:
int open(const char *pathname, int flags, mode_t mode);
- close():关闭文件,释放文件描述符所占用的资源。函数原型为:
int close(int fd);
- read():从文件中读取数据到缓冲区中。函数原型为:
ssize_t read(int fd, void *buf, size_t count);
- write():将数据从缓冲区写入到文件中。函数原型为:
ssize_t write(int fd, const void *buf, size_t count);
- lseek():移动文件指针到指定位置,用于随机访问文件。函数原型为:
off_t lseek(int fd, off_t offset, int whence);
- fcntl():对文件描述符进行各种控制操作,比如设置文件状态标志、获取/设置文件描述符属性等。函数原型为:
int fcntl(int fd, int cmd, ...);
- ioctl():对设备进行各种控制操作,比如获取/设置设备参数、发送控制命令等。函数原型为:
int ioctl(int fd, unsigned long request, ...);
- select():监视多个文件描述符,等待它们之一变为可读或可写,通常用于实现多路复用 I/O。函数原型为:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- socket():创建一个套接字,用于网络通信。函数原型为:
int socket(int domain, int type, int protocol);
- bind():将一个本地地址绑定到套接字上。函数原型为:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- listen():开始监听传入的连接请求。函数原型为:
int listen(int sockfd, int backlog);
- accept():接受一个传入的连接请求。函数原型为:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
这些函数和系统调用提供了对文件、设备和网络的基本操作,使得程序可以进行输入输出操作,实现文件读写、网络通信等功能。这些函数构成了 UNIX 系统的标准 I/O 接口,被广泛应用于 UNIX 和类 UNIX 系统上的软件开发。
标准输入输出流它们是文件描述符,用于标识输入/输出流的实例。标准输入流 stdin 和标准输出流 stdout 分别对应文件描述符 0 和文件描述符 1,它们都是在程序启动时由系统自动打开的。当程序需要从标准输入流中读取数据时,它会使用 read 系统调用从文件描述符 0 中读取数据;当程序需要向标准输出流中写入数据时,它会使用 write 系统调用向文件描述符 1 中写入数据。这个缓存的指针是保存在struct FILE这个结构里的。Linux的GCC的,_IO_buf_base就是这个缓冲区:
struct _IO_FILE
{
int _flags;
char *_IO_read_ptr;
char *_IO_read_end;
char *_IO_read_base;
char *_IO_write_base;
char *_IO_write_ptr;
char *_IO_write_end;
char *_IO_buf_base
char *_IO_buf_end;
char *_IO_save_base;
char *_IO_backup_base;
char *_IO_save_end;
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
__off_t _old_offset;
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
__off64_t _offset;
struct _IO_codecvt *_codecvt;
struct _IO_wide_data *_wide_data;
struct _IO_FILE *_freeres_list;
void *_freeres_buf;
size_t __pad5;
int _mode;
char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)];
};
8.3 PRINTF的实现分析
static int printf(const char *fmt, ...)
{
va_list args;
int i;
va_start(args, fmt);
write(1,printbuf,i=vsprintf(printbuf, fmt, args));
va_end(args);
return i;
}
vsprintf函数的功能是获得打印出的字符个数,接着调用write函数,往文件描述符为1的文件里写字符。然后调用字符显示驱动子程序,这个程序从文件描述符为1的文件(标准输出)中获取要输出的内容,从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 GETCHAR的实现分析
当程序调用getchar时,程序等待用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车(回车也在缓冲区中)。
当用户按下回车后,getchar从stdio流(标准输入,文件描述符为0)中读取一个字符。getchar的返回值是读入字符的ascii码,如果出错返回-1,并且将用户输入的字符显示到屏幕上。如果用户在按下回车前输入了多个字符,这些字符会留在stdio中,等用户下次调用getchar时,会先去stdio中取,没了才会等用户输入。
异步异常-键盘中断的处理:当用户按键触发键盘中断,操作系统调用键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
8.5本章小结
本章通过对Unix I/O的分析,了解了Linux下的IO管理方法,又简单介绍了一些Unix IO接口和函数。最后通过Unix IO的底层接口,分析了printf和getchar函数的实现方式。
结论
作为一个简单的程序,麻雀虽小五脏俱全,hello
向我们展示了一个程序完整的一生,它经历了:
- 预处理:经过预处理器(cpp),根据以字符#开头的命令,修改原始程序,生成hello.i。
- 编译:经过编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,一个汇编语言程序。
- 汇编:经过汇编器(as)将hello.s汇编语言翻译成机器语言指令,并把这些指令放到hello.o可重定位目标文件中。hello.o是一个二进制文件。
- 链接:hello程序中调用了C标准库中的函数,所以通过动态链接器ld将使用到了标准库和hello.o进行链接,并在加载时进行符号解析和重定位。
- 进程创建:在shell中运行,首先需要shell通过fork为hello创建一个进程。
- 运行:shell子进程创建完毕之后,调用exceve函数,将hello的内容复制到内存中,进行动态链接,然后开始运行指令。
- 存储管理:hello运行过程中指令,数据,函数等等的调用都需要存储管理的帮助。TLB,多级页表使得能快速的实现VA向PA的转换。三级Cache以物理地址存取的方式快速获得数据交给CPU处理。
- IO管理:IO管理实现了用户通过键盘屏幕和hello进行交互过程。
- 信号处理:hello所在进程还能收到信号(如ctrl-z和ctrl-c)并进行处理。
- 进程回收:在hello执行完毕,在return之后,会被父进程回收,清楚所占用的空间。
计算机系统是复杂的,一个简简单单的hello程序的一生竟也如此丰富,让我们不禁感慨这一领域的前辈们的智慧;计算机系统是美丽的,是人们用智慧创造出来的无比成功的工具。
附件
文件名 | 文件作用 |
---|---|
hello.i | 预处理后的文件 |
hello.s | 编译后文件 |
hello.o | gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o |
汇编后可重定位目标文件 | |
hello | ld链接后可执行文件 |
elf_hello_o.txt | readelf -a hello.o |
hello.o ELF 格式 | |
elf_hello.txt | readelf -a hello |
hello ELF 格式 | |
dump_hello_o.txt | objdump -d -r hello.o |
hello.o反汇编 | |
dump_hello.txt | objdump -d -r hello |
hello反汇编 |
参考文献
1.C/C++ |预处理详解-阿里云开发者社区
2.https://blog.csdn.net/u012138730/article/details/82805675剖析ELF文件格式的内容———文件头,段表,符号…(第三章)
3.https://blog.csdn.net/leapmotion/article/details/131040518linux下动态链接过程
4.https://zhuanlan.zhihu.com/p/606649159【默子的操作系统】进程详解
5.https://blog.csdn.net/qq_62651433/article/details/130828212