——程序人生-Hello’s P2P
摘 要
Hello world的一生由hello.c文件开始,经过被gcc整合的功能块cpp(预处理器),ccl(编译器),as(汇编器)之后变为可重定位的目标文件,再经由ld(链接器)的符号解析和重定位之后成功变为可执行目标文件。本文通过分析一个hello.c的完整的生命周期,从它开始被编译,到被汇编、链接、在进程中运行,讲解了Linux计算机系统执行一个程序的完整过程。
1.1 Hello简介
1.2 环境与工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1 预处理的概念与作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.4 Hello.o的结果解析
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.6 hello的执行流程
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.6 hello的异常与信号处理
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
7.10本章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
附件
参考文献
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
硬件环境
X64 CPU;2GHz;4G RAM;256GHD Disk
软件环境
Windows10 64位;VMware 14;Ubuntu 18.04
开发工具
Visual Studio 2017 64位;CodeBlocks;vim,gpedit+gcc,as,ld,edb,readelf,HexEdit
hello.i —— 修改了的源程序(文本)
hello.s —— 汇编程序(文本)
hello.o —— 可重定位目标程序(二进制)
hello —— 可执行目标程序(二进制)
本章简要的概括了hello world一生的两个阶段:P2P与020的过程,以及进行实验时的软硬件环境及开发与调试工具,以及在本论文中生成的中间结果文件。
2.1.1预处理的概念
在编译之前进行的处理。预处理器cpp根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,将引用的所有库展开合并成为一个完整的文本文件。
2.1.2预处理的作用
预处理主要体现在宏定义、文件包含、条件编译三个方面,预处理命令以符号“#”开头。预处理会导入宏定义与文件、头文件中的内容,使得程序能完整、正常的运行,预处理生成了hello.i的源代码文本文件。
通过输入gcc hello.c -E -o hello.i可以对hello.c进行预处理,得到hello.i
在预处理文本文件hello.i中,首先是对文件包含中系统头文件的寻址和解析
hello.i文件可以看到,它的前面头文件等等被展开了,变成了很多以#开头的内容,在原有代码的基础上,将头文件stdio.h的内容引入,例如声明函数、定义结构体、定义变量、定义宏等内容。hello.i的最下面是我们熟悉的C语言程序。
本章通过了解预处理的概念及作用,进行Ubuntu下预处理操作,并讲述了编译器的工作,以及我们怎样在Ubuntu下将一个与处理文件变为一个汇编代码文件,解释了在汇编代码中是如何实现c语言中的各项数据和指令的。
3.1.1编译的概念
编译过程就是将预处理后得到的预处理文件进行词法分析、语法分析、语义分析、优化后,生成汇编代码文件,本文中是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序的过程,生成一个hello.s的汇编语言源程序文件。
3.1.2编译的概念
编译的作用是将高级语言转变为更易于计算机读懂的汇编语言,同时它还可以进行语法检查、程序优化。
Ubuntu终端下进入hello.c所在文件,输入指令gcc hello.i -S -o hello.s,按下回车即可。
得到的hello.s就是编译后得到的汇编语言源程序文件,通过gedit查看它,发现它的头部声明了全局变量和它们存放的节段,接下来是将源程序的命令汇编得到的代码,接下来对它们进行分析。
3.3.1 全局变量
在hello.c中有一个全局变量sleepsecs,它被定义成int型,但在编译器编译的过程中将它优化为了long型,这里编译器进行了隐式的类型转换,我们给它赋值为2.5,查看sleepsecs的值时发现sleepsecs = 2,它被存放在.rotate节中。
3.3.2 局部变量
通过观察这里,我们发现在.L2中声明了一个局部变量i,将其存储在-4(%rbp)中,可以得知在处理局部变量时,编译到当前位置才去申请这样一个内存空间的。
3.3.3 赋值
程序中涉及的赋值操作有:
int sleepsecs=2.5 :因为sleepsecs是全局变量,所以直接在.data节中将sleepsecs声明为值2的long类型数据。
i=0:整型数据的赋值使用mov指令完成,根据数据的大小不同使用不同后缀,分别为:
指令 b w l q
大小 8b (1B) 16b (2B) 32b (4B) 64b (8B)
因为i是4B的int类型,所以使用movl进行赋值。
3.3.4 类型转换
程序中涉及隐式类型转换的是:int sleepsecs=2.5,将浮点数类型的2.5转换为int类型。当在double或float向int进行类型转换的时候,程序改变数值和位模式的原则是:值会向零舍入。例如1.999将被转换成1,-1.999将被转换成-1。进一步来讲,可能会产生值溢出的情况,与Intel兼容的微处理器指定位模式[10…000]为整数不确定值,一个浮点数到整数的转换,如果不能为该浮点数找到一个合适的整数近似值,就会产生一个整数不确定值。
浮点数默认类型为double,所以上述强制转化是double强制转化为int类型。遵从向零舍入的原则,将2.5舍入为2。
3.3.5 算术操作
汇编语言中加减乘除四则运算是通过语句来实现的:
指令效果
leaq S,D D=&S
INC D D+=1
DEC D D-=1
NEG D D=-D
ADD S,D D=D+S
SUB S,D D=D-S
IMULQ S R[%rdx]:R[%rax]=SR[%rax](有符号)
MULQ S R[%rdx]:R[%rax]=SR[%rax](无符号)
IDIVQ S R[%rdx]=R[%rdx]:R[%rax] mod S(有符号)
R[%rax]=R[%rdx]:R[%rax] div S
DIVQ S R[%rdx]=R[%rdx]:R[%rax] mod S(无符号)
R[%rax]=R[%rdx]:R[%rax] div S
i++,对计数器i自增,使用程序指令addl,后缀l代表操作数是一个4B大小的数据。
汇编中使用leaq .LC1(%rip),%rdi,使用了加载有效地址指令leaq计算LC1的段地址%rip+.LC1并传递给%rdi。
3.3.6 关系操作
关系操作就是比较两个变量的大小情况,通过cmpl来执行,在cmpl中比较两个数的大小,用后一个数减去前一个数得到结果的情况来设置标志位,接下来可以通过设置的标志位进行跳转等操作。
3.3.7 控制转移之条件语句
通过cmpl进行比较,根据比较的结果通过jx进行跳转,跳转方式可以通过查看跳转表得到
3.3.8 控制转移之循环语句
这里就是一个循环语句的开始,可以发现我们的循环条件是i < 10,在这里被优化为了i <= 9,每次将计数器的值与9进行比较,若小于等于则跳转到循环内部L4执行.
循环内部语句如下,在每次执行.L4结束后都将-4(%rbp)加1,因此它是i,起到一个计数器的作用。
3.3.9 函数操作
参数传递:在函数的参数传递中使用不同的寄存器来保存第x个参数
函数调用:使用call语句来实现函数的调用。
函数返回:函数的返回值保存在%rax中,将需要返回的变量值存在%rax中,在进行函数的操作之后ret即可返回%rax中的值。
程序中涉及函数操作的有:
main函数:
传递控制,main函数因为被调用call才能执行(被系统启动函数__libc_start_main调用),call指令将下一条指令的地址dest压栈,然后跳转到main函数。
传递数据,外部调用过程向main函数传递参数argc和argv,分别使用%rdi和%rsi存储,函数正常出口为return 0,将%eax设置0返回。
分配和释放内存,使用%rbp记录栈帧的底,函数分配栈帧空间在%rbp之上,程序结束时,调用leave指令,leave相当于mov %rbp,%rsp,pop %rbp,恢复栈空间为调用之前的状态,然后ret返回,ret相当pop IP,将下一条要执行指令的地址设置为dest。
printf函数:
传递数据:第一次printf将%rdi设置为“Usage: Hello 学号 姓名!\n”字符串的首地址。第二次printf设置%rdi为“Hello %s %s\n”的首地址,设置%rsi为argv[1],%rdx为argv[2]。
控制传递:第一次printf因为只有一个字符串参数,所以call puts@PLT;第二次printf使用call printf@PLT。
exit函数:
传递数据:将%edi设置为1。
控制传递:call exit@PLT。
sleep函数:
传递数据:将%edi设置为sleepsecs。
控制传递:call sleep@PLT。
getchar函数:
控制传递:call gethcar@PLT
本阶段完成了对hello.i的编译工作。使用编译指令可以将其转换为.s汇编语言文件。完成该阶段转换后,可以进行下一阶段的汇编处理。
4.1.1 汇编的概念
汇编代码文件(由汇编指令构成)称为汇编语言源程序,汇编程序(汇编器)用来将汇编语言源程序转换为机器指令序列(机器语言程序)。
4.1.2 汇编的作用
汇编器(as)将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,.o文件是一个二进制文件,它包含程序的指令编码。这个过程称为汇编,亦即汇编的作用。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
通过as hello.s -o hello.o进行汇编,得到hello.o
二进制文件用文本编辑器打开为空
利用readelf -a hello.o > hello.elf生成hello.o的全部信息
4.3.1 ELF Header
以16B的序列Magic开始,Magic描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
4.3.2 Section Headers
节头部表,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。
4.3.3 重定位节.rela.text
一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。如图4.4,图中8条重定位信息分别是对.L0(第一个printf中的字符串)、puts函数、exit函数、.L1(第二个printf中的字符串)、printf函数、sleepsecs、sleep函数、getchar函数进行重定位声明。
使用 objdump -d -r hello.o > hello.objdump获得反汇编代码。
4.4.1分支转移
反汇编代码跳转指令的操作数使用的不是段名称如.L3,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
4.4.2函数调用
在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待静态链接的进一步确定。
4.4.3全局变量访问
在.s文件中,访问rodata(printf中的字符串),使用段名称+%rip,在反汇编代码中0+%rip,因为rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
本章简述了将hello.s生成hello.o,从汇编代码生成机器代码的过程,比较了汇编代码与机器代码的异同。
5.5.1链接的概念
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
5.5.2链接的作用
链接使得分离编译成为可能,能够将一个大型的应用程序分解成为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
– $gcc –static –o myproc main.o test.o
– $ld –static –o myproc main.o test.o
–static 表示静态链接,如果不指定-o选项,则可执行文件名为“a.out”
在ELF格式文件中,Section Headers对hello中所有的节信息进行了声明,其中包括大小Size以及在程序中的偏移量Offset,因此根据Section Headers中的信息我们就可以用HexEdit定位各个节所占的区间(起始位置,大小)。其中Address是程序被载入到虚拟地址的起始地址。
5.3.1 ELF Header
这部分描述了elf文件总的信息
5.3.2 Section Headers
这部分描述了hello中出现的各个节的类型、载入到虚拟内存后的地址(Address)、节头表所对应字节大小(Size)以及这个节的地址偏移量(Offset)等信息。
使用edb打开hello程序,通过edb的Data Dump窗口查看加载到虚拟地址中的hello程序。
在0x400000~0x401000段中,程序被载入,自虚拟地址0x400000开始,自0x400fff结束,这之间每个节(开始 ~ .eh_frame节)的排列即开始结束同图5.2中Address中声明。
如图5.3,查看ELF格式文件中的Program Headers,程序头表在执行的时候被使用,它告诉链接器运行时加载的内容并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方面的信息。在下面可以看出,程序包含8个段:
PHDR保存程序头表。
INTERP指定在程序已经从可执行文件映射到内存之后,必须调用的解释器(如动态链接器)。
LOAD表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串)、程序的目标代码等。
DYNAMIC保存了由动态链接器使用的信息。
NOTE保存辅助信息。
GNU_STACK:权限标志,标志栈是否是可执行的。
GNU_RELRO:指定在重定位结束之后那些内存区域是需要设置只读。
使用objdump -d -r hello > hello.objdump 获得hello的反汇编代码。
通过比较hello.objdump和helloo.objdump了解链接器。
1)函数个数:在使用ld命令链接的时候,指定了动态链接器为64的/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o中主要定义了程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入。
2)函数调用:链接器解析重定条目时发现对外部函数调用的类型为R_X86_64_PLT32的重定位,此时动态链接库中的函数已经加入到了PLT中,.text与.plt节相对距离已经确定,链接器计算相对距离,将对动态链接库中函数的调用值改为PLT中相应函数与下条指令的相对地址,指向对应函数。对于此类重定位链接器为其构造.plt与.got.plt。
3).rodata引用:链接器解析重定条目时发现两个类型为R_X86_64_PC32的对.rodata的重定位(printf中的两个字符串),.rodata与.text节之间的相对距离确定,因此链接器直接修改call之后的值为目标地址与下一条指令的地址之差,指向相应的字符串。
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
程序名称 程序地址
ld-2.27.so!_dl_start 0x7fc5 60f02ea0
ld-2.27.so!_dl_init 0x7fc5 60f11630
hello!_start 0x400500
libc-2.27.so!__libc_start_main 0x7fce 8c867ab0
-libc-2.27.so!__cxa_atexit 0x7fce 8c889430
-libc-2.27.so!__libc_csu_init 0x4005c0
hello!_init 0x400488
libc-2.27.so!_setjmp 0x7fce 8c884c10
-libc-2.27.so!_sigsetjmp 0x7fce 8c884b70
–libc-2.27.so!__sigjmp_save 0x7fce 8c884bd0
hello!main 0x400532
hello!puts@plt 0x4004b0
hello!exit@plt 0x4004e0
*hello!printf@plt –
*hello!sleep@plt –
*hello!getchar@plt –
ld-2.27.so!_dl_runtime_resolve_xsave 0x7fce 8cc4e680
-ld-2.27.so!_dl_fixup 0x7fce 8cc46df0
–ld-2.27.so!_dl_lookup_symbol_x 0x7fce 8cc420b0
libc-2.27.so!exit 0x7fce 8c889128
在动态链接的过程中,程序会生成一个共享库,它可以加载到任意的内存地址,并和一个内存中的程序链接起来,它在加载和运行时由动态链接器完成。
在处理全局函数时,对于x函数会生成一个x@plt函数,这个函数对应重定位后的跳转地址。
在_dl_init执行前偏移量中全为0,在它执行后偏移量变为了相应的偏移值,因此可以发现_dl_init操作是加载计算当前内存地址的偏移量。
本章简述了链接器的工作,分析了静态链接和动态链接两种形式,以及链接器的符号解析和重定位的过程,通过链接和重定位,得到了可执行的二进制文件。
6.1.1进程的概念
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
6.1.2进程的作用
进程提供给应用程序两个关键抽象,逻辑控制流和私有地址空间:
逻辑控制流进程使得每个程序似乎独占地使用CPU,它由通过OS内核的上下文切换机制提供。
私有地址空间使得每个程序似乎独占地使用内存系统,它由OS内核的虚拟内存机制提供。
Shell是用户和Linux内核之间的接口程序,用户输入的命令通过shell解释,然后传给Linux内核,然后将内核的处理结果翻译给用户。
处理流程:首先shell读取用户输入的命令并进行解析,得到参数列表,然后检查这条命令是否是内核命令,如果是则直接执行,如果不是则fork子进程,启动加载器在当前进程中加载并运行程序。
父进程通过调用fork函数创建一个新的运行的子进程:
pid_t fork(void);
fork子进程时,系统创建一个与父进程几乎但不完全相同的子进程,子进程得到与父进程用户级虚拟地址空间相同但独立的一份副本,包括代码、数据段、堆、共享库以及用户栈,子进程获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中的内容,但它们有着不同的PID,在父进程中,fork返回子进程的PID,在子进程中,fork返回0。
创建进程后,在子进程中通过判断pid即fork()函数的返回值,判断处于子进程,则会通过execve函数在当前进程的上下文中加载并运行一个新程序。execve加载并运行可执行目标文件,且带参数列表argv和环境变量列表envp。只有当出现错误时,execve才会返回到调用程序。
在execve加载了可执行程序之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,即可执行程序的main函数。此时用户栈已经包含了命令行参数与环境变量,进入main函数后便开始逐步运行程序。
多个流并发地执行的一般现象被称为并发。一个进程和其他进轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
hello程序执行过程中同样存储时间分片,与操作系统的其他进行并发运行。并发执行涉及到操作系统内核采取的上下文交换策略。内核为每个进程维持一个上下文,上下文就是内核重新启动一个先前被抢占的进程所需的状态。
在执行过程中,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这个过程称为调度。
在此基础上,hello程序与操作系统其他进程通过操作系统的调度,切换上下文,拥有各自的时间片从而实现并发运行。
程序在涉及到一些操作时,例如调用一些系统函数,内核需要将当前状态从用户态切换到核心态,执行结束后再及时改用户态,从而保证系统的安全与稳定。
hello执行过程中可能出现四类异常:中断、陷阱、故障和终止。
中断是来自I/O设备的信号,异步发生,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。
hello执行过程中,可能会遇到各种异常,信号则是一种通知用户异常发送的机制。例如较为底层的硬件异常以及较高层的软件事件,比如Ctrl-Z和Ctrl-C,分别触发SIGCHLD和SIGINT信号。
收到信号后进程会调用相应的信号处理程序对其进行处理。
本章主要讲述了hello程序再从可执行文件到能真正再系统中执行的一个过程,以及是怎么再程序之中处理异常的方法,其中涉及中断和陷阱的两种的异常情况。并实践了再程序中测试不同的shell程序对于hello程序的影响。
7.1.1逻辑地址
Intel为了兼容,将远古时代的段式内存管理方式保留了下来。逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。以上例,我们说的连接器为A分配的0x08111111这个地址就是逻辑地址。
7.1.2线性地址
跟逻辑地址类似,它也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件页式内存的转换前地址。
7.1.3虚拟地址
这是对整个内存(不要与机器上插那条对上号)的抽像描述。它是相对于物理内存来讲的,可以直接理解成“不直实的”,“假的”内存,例如,一个0x08000000内存地址,它并不对就物理地址上那个大数组中0x08000000 - 1那个地址元素;
之所以是这样,是因为现代操作系统都提供了一种内存管理的抽像,即虚拟内存(virtual memory)。进程使用虚拟内存中的地址,由操作系统协助相关硬件,把它“转换”成真正的物理地址。这个“转换”,是所有问题讨论的关键。
有了这样的抽像,一个程序,就可以使用比真实物理地址大得多的地址空间。因为转换后的物理地址并非相同的,甚至多个进程可以使用相同的地址。
7.1.4物理地址
用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应,是内存单元的真正地址。
Intel处理器从逻辑地址到线性地址的变换通过段式管理,介绍段式管理就必须了解段寄存器的相关知识。段寄存器对应着内存不同的段,有栈段寄存器(SS)、数据段寄存器(DS)、代码段寄存器(CS)和辅助段寄存器(ES/GS/FS)。其大体对应关系如下图:
CPU的页式内存管理单元,负责把一个线性地址,最终翻译为一个物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。
另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。
这里注意到,这个total_page数组有2^20个成员,每个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。
如上图,
1、分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。万里长征就从此长始了。
2、每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中,将别个的保存下来。
3、每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位)
依据以下步骤进行转换:
1、从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
2、根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
3、根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
4、将页的起始地址与线性地址中最后12位相加,得到最终我们想要的葫芦;
这个转换过程,应该说还是非常简单地。全部由硬件完成,虽然多了一道手续,但是节约了大量的内存,还是值得的。那么再简单地验证一下:
1、这样的二级模式是否仍能够表示4G的地址;
页目录共有:2^10项,也就是说有这么多个页表
每个目表对应了:2^10页;
每个页中可寻址:2^12个字节。
还是2^32 = 4GB
2、这样的二级模式是否真的节约了空间;
也就是算一下页目录项和页表项共占空间 (2^10 * 4 + 2 ^10 *4) = 8KB。
按<深入理解计算机系统>中的解释,二级模式空间的节约是从两个方面实现的:
A、如果一级页表中的一个页表条目为空,那么那所指的二级页表就根本不会存在。这表现出一种巨大的潜在节约,因为对于一个典型的程序,4GB虚拟地址空间的大部份都会是未分配的;
B、只有一级页表才需要总是在主存中。虚拟存储器系统可以在需要时创建,并页面调入或调出二级页表,这就减少了主存的压力。只有最经常使用的二级页表才需要缓存在主存中。——不过Linux并没有完全享受这种福利,它的页表目录和与已分配页面相关的页表都是常驻内存的。
值得一提的是,虽然页目录和页表中的项,都是4个字节,32位,但是它们都只用高20位,低12位屏蔽为0——把页表的低12屏蔽为0,是很好理解的,因为这样,它刚好和一个页面大小对应起来,大家都成整数增加。计算起来就方便多了。但是,为什么同时也要把页目录低12位屏蔽掉呢?因为按同样的道理,只要屏蔽其低10位就可以了,不过我想,因为12>10,这样,可以让页目录和页表使用相同的数据结构,方便。
在这里要用到翻译后备缓冲器(TLB),每个VA被分为VPN、VPO,每个VPN又分为三段,根据TLB标记(TLBT)和TLB索引(TLBI)到TLB中找对应的虚拟页号(PPN),找到的PPN+VPO即为物理地址。
在TLB模式下,如果能直接命中即可直接得到PPN,若发生缺页则要去页表里再进行查找,VPN被分为了4段,在查找时通过每一级页表一级一级往下找,最后找到相应的PPN,加上虚拟页面偏移量VPO即可得到物理地址。
处理器对物理内存中数据的访问,同样需要经过缓存,即Cache,主流的处理器通常采用三级Cache。层与层之间按照以下原则进行读与写:
读取数据时,首先在高速缓存中查找所需字w的副本。如果命中,立即返回字w给CPU。如果不命中,从存储器层次结构中较低层次中取出包含字w的块,将这个块存储到某个高速缓存行中(可能会驱逐一个有效的行),然后返回字w。
下面具体三类Cache进行分析:
(1)直接映射高速缓存
直接映射高速缓存每个组只有一行,当CPU执行一条读内存字w的指令,它会向L1高速缓存请求这个字。如果L1高速缓存中有w的一个缓存副本,那么就会得到L1高速缓存命中,高速缓存会很快抽取出w,并将它返回给CPU。否则就是缓存不命中,当L1高速缓存向主存请求包含w的块的一个副本时,CPU必须等待。当被请求块最终从内存到达时,L1高速缓存将这个块存放在它的一个高速缓存行里,从被存储的块中抽取出字w,然后将它返回给CPU。确定是否命中然后抽取的过程分为三步:1)组选择;2)行匹配;3)字抽取。
2)组相联高速缓存
组相联高速缓存每个组内可以多于一个缓存行,总体逻辑类似于直接映射高速缓存,不同之处在于行匹配时每组有更多的行可以尝试匹配,遍历每一行。如果不命中,有空行时也就是冷不命中则直接存储在空行;如果没有空行也就是冲突不命中,则替换已有行,通常有LFU(最不常使用)、LRU(最近最少使用)两者替换策略。
(3)全相联高速缓存
全相联高速缓存只有一个组,且这个组包含所有的高速缓存行(即E =
C/B)。对于全相联高速缓存,因为只有一个组,组选择变的十分简单。地址中不存在索引位,地址只被划分为一个标记位和一个块偏移。行匹配和字选择同组相联高速缓存。
写入数据时,假设我们要写一个已经缓存了的字w,在高速缓存中更新了它的w的副本之后,有两种方法来更新w在层次结构中紧接着低一层中的副本。分别是直写和写回,在这里分别介绍:
(1)直写
立即将w的高速缓存块写回到紧挨着的低一层中。优点是简单,缺点则是每次写都会引起总线流量。其处理不命中的方法是非写分配,即避开高速缓存,直接将这个字写到低一层去。
(2)写回
尽可能地推迟更新,只有当替换算法要驱逐这个更新过的块时,才把它写到紧接着的低一层中。优点是能显著地减少总线流量,缺点是增加了复杂性,必须为每个高速缓存行增加一个额外的修改位,表明是否被修改过。写回处理不命中的方法是写分配,加载相应低一层中的块到高速缓存中,然后更新这个高速缓存块,利用了写的空间局部性,但会导致每次不命中都会有一个块从低一层传到高速缓存。
通过这样的Cache读写机制,实现了从CPU寄存器到L1高速缓存,再到L2高速缓存,再到L3高速缓存,再到物理内存的访问,有效的提高了CPU访问物理内存的速度。
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。
execve函数在当前进程中加载并运行新程序a.out时:
删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
创建新的区域结构,这些新的区域都是私有的、写时复制的,代码和初始化数据映射到.text和.data区,.bss和栈堆映射到匿名文件。
映射共享区域。如果a.out程序与共享对象链接,那么这些对象都是动态链接到这个程序的,再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC)。设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
缺页故障:当mmu翻译虚拟地址时发现在页表项中,该页表项的有效位为0(这个数据还在磁盘中,未被加载到内存中),此时会触发缺页中断,进程陷入内核模式,进入异常处理程序,异常处理程序将一个页块大小的数据加载到内存中,之后将控制返还给源程序。如果选择的内存页被修改过,则要先将这个块交换会磁盘之后再将目标数据加载到内存页
printf函数会调用malloc,下面简述动态内存管理的基本方法与策略:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
7.9.1 隐式空闲链表
隐式空闲链表通过头部中的大小字段隐含地连接所有块
7.9.2 显式空闲链表
显式空闲链表在空闲块中使用指针连接空闲块。它将空闲块组织为某种形式的显式数据结构,只保留空闲块链表,而不是所有块。在每个空闲块中,都包含一个前驱(pred)和后继(succ)指针。
维护显式空闲链表有两种方式,分别为后进先出(LIFO)和按照地址顺序来维护。后进先出的顺序维护将新释放的块放置在链表的开始处,使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。按照地址顺序来维护链表时,链表中每个块的地址都小于它的后继的地址,按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
7.9.3 寻找空闲块
首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块。
下一次适配:从链表中上一次查询结束的地方开始搜索空闲链表,选择第一个合适的空闲块。
最佳适配:查询链表,选择一个最好的空闲块。
7.9.4 分配空闲块——分割
当分配块比空闲块小时,我们可以把空闲块分割成两部分,并将多余的空间重新加到空闲链表中,以减少内部碎片。
7.9.5 空闲块的合并——带边界标记的合并
合并后面的空闲块时,当前块的头部指向下一个块的头部,可以检查这个指针以判断下一个块是否是空闲的,如果是,就将它的大小简单地加到当前块头部的大小上,使得两个块在常数的时间内被合并。
合并前面的空闲块时,在每个块的结尾处添加一个脚部,它是头部的一个副本,分配器可以通过检查它的脚部判断前一个块的起始状态和状态。
本章着重讲述了怎么解决hello可执行文件从磁盘到进程的上下文中,之后怎么解决该进程的“独占内存”(虚拟内存)手段,以及在执行的过程之中,处理器是怎么将这一虚拟内存转化为物理地址,以及遇到页表中有未加载到内存的数据时怎么处理(缺页中断处理程序),最后回顾了一下动态分配内存是怎么在底层实现的。
设备的模型化:所有IO设备都被模型化为文件,所有的输入和输出都能被当做相应文件的读和写来执行。
设备管理:Linux内核有一个简单、低级的接口,成为Unix I/O,是的所有的输入和输出都能以一种统一且一致的方式来执行。
Unix I/O接口统一操作:
打开文件:int open (char *filename, int flags, mode_t mode);
关闭文件:int close (int fd);
读文件:ssize_t read (int fd, void *buf, size_t n);
写文件:ssize_t write (int fd, const void *buf, size_t n);
代码如下:
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;
}
(char*)(&fmt) + 4) 表示的是…可变参数中的第一个参数的地址。而vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。接着从vsprintf生成显示信息,到write系统函数,直到陷阱系统调用 int 0x80或syscall。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
getchar代码如下:
int getchar(void)
{
char c;
return (read(0,&c,1)==1)?(unsigned char)c:EOF
}
getchar函数通过调用read函数返回字符。其中read函数的第一个参数是描述符fd,0代表标准输入。第二个参数输入内容的指针,这里也就是字符c的地址,最后一个参数是1,代表读入一个字符,符号getchar函数读一个字符的设定。read函数的返回值是读入的字符数,如果为1说明读入成功,那么直接返回字符,否则说明读到了buf的最后。
read函数同样通过sys_call中断来调用内核中的系统函数。键盘中断处理子程序会接受按键扫描码并将其转换为ASCII码后保存在缓冲区。然后read函数调用的系统函数可以对缓冲区ASCII码进行读取,直到接受回车键返回。
这样,getchar函数通过read函数返回字符,实现了读取一个字符的功能。
本章的讲述了linux系统下的I/O设备的管理方法,这套管理方法为软件提供了统一而一致的接口来控制复杂而通常大不相同的I/O硬件设备。同时利用hello world这一实例来茶阐述了系统级别的printf和getchar是怎么实现的。
hello首先在计算机内被其他程序进行了一番翻译,经过预处理变为hello.i,再经过编译器变为汇编程序hello.s,再经过汇编器变为可重定位的二进制目标程序hello.o,然后经过链接器生成hello可执行的二进制目标程序;
在shell中经过了fork和execve,把hello加载到其中;
然后在磁盘中去读取它,在运行的过程中接受键盘的信号shell对其做出不同的处理,映射虚拟内存进行访问,进行动态内存分配;
最后进程终止,被shell回收。
Hello一路走来很不容易,我们在面前只看到了它简单的运行结果,但其实后面有编译器汇编器链接器等等在一起工作,使它能完美的呈现在我们面前。
写完大作业对Linux下的各种命令更加熟悉了,学习了一下edb的使用。
计算机系统是个很神奇很伟大的东西,它需要我们花更多的时间和精力去深入理解它……光是这学期学的知识还远远不够,以后也要深入理解计算机系统!
hello.i: hello.c预编译的结果,用于研究预编译的作用以及进行编译器的下一步编译操作。
hello.s: hello.i编译后的结果,用于研究汇编语言以及编译器的汇编操作,可以与hello.c对应,分析底层的实现。
hello.o: hello.s汇编后的结果,可重定位目标程序,没有经过链接,用于链接器或编译器链接生成最终可执行程序。
hello.out: hello.o链接后生成的可执行目标文件,可以用来反汇编或者通过EDB、GDB等工具分析链接过程以及程序运行过程,包括进入main函数前后发生的过程。
hello: 同hello.out,由gcc -m64 -no-pie -fno-PIC hello.c -o
hello命令直接生成。
hello.o.s: 对可重定位目标文件反汇编得到,可以与对可执行目标文件反汇编得到的代码对比来分析链接过程。
asm.s: 对可执行目标文件反汇编得到,可以用来分析链接过程与寻址过程。
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26].
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] 雷迎春,龚奕利.计算机系统与程序员之间的桥梁[J].程序员,2004(07):119
[6] 刘江.深入理解计算机之道[J].程序员,2006(09):130-131+10.