赞
踩
摘要
本文以一个简单的hello.c程序为例,介绍了程序在Linux系统上的一生。通过使用Linux上的各种工具,详细揭露了hello.c文件从变为到可执行文件程序存到硬盘开始,之后再从载入内存进程,直到程序运行完毕成为僵尸进程,最后被回收的整个完整生命历程。从而加强对计算机系统预处理、编译、汇编、链接、进程管理、存储管理、I/O管理等各环节的整体认识。
关键词:计算机系统;Linux;程序;编译;汇编;链接;进程管理;I/O管理
目录
1.1.1 P2P(From Program to Process)过程
1.1.2 O2O(From Zero-0 to Zero-0)过程
在Linux中,最开始程序员通过键盘编辑创建了hello.c程序(Program),但这个程序到进程(Process)还有以下的漫长过程。
hello.c文件要通过编译系统变换为hello可执行程序,具体过程如图1所示。首先hello.c文件经过cpp预处理器得到文本文件hello.i,之后经过ccl编译器生成汇编程序hello.s,接着再经过as汇编器生成可重定位目标程序hello.o,最后经过ld链接器,与其它用到的库函数可重定位文件链接,生成可执行程序hello。
当执行hello程序时,系统会新创建一个进程再将hello程序加载进入,从而最终实现了从程序到进程的整个过程。
当执行hello程序后,shell创建新的子进程,在其中调用execve函数将hello程序由虚拟内存加载进入物理内存,然后运行main函数,shell调用waitpid函数,当hello运行完毕成为僵尸进程后,shell就将该僵尸进程回收,同时释放虚拟内存并删除hello的相关内容,这时hello运行过的痕迹都被清空,控制权重新传回shell,等待运行下一条输入的命令,从而实现了O2O的全过程。
硬件环境:X64 CPU;2.9GHz;16G RAM;512G SSD
软件环境:Windows10 64位;VMware17.0;Ubuntu 22.04.2 LTS 64位
开发与调试工具:Visual Studio 2022,gcc,vim,edb,objdump,readelf,Codeblocks
文件名 | 文件描述 |
hello.c | 源程序 |
hello.i | hello.c预处理后的预编译处理文件 |
hello.s | hello.i编译后的汇编文件 |
hello.o | hello.s汇编后的可重定位目标文件 |
hello | hello.o链接后的可执行目标文件 |
hello_o_asm.txt | hello.o反汇编后文件 |
hello_asm.txt | hello反汇编后文件 |
hello_o.elf | hello.o的ELF格式文件 |
hello.elf | hello的ELF格式文件 |
printf.txt | print函数的函数体 |
getchar.txt | getchar函数的函数体 |
本章概述了hello的P2P和O2O的过程。此外,还介绍了本实验用到的硬软件环境和开发调试工具,最后介绍了本次实验的中间结果文件。
概念:广义的预处理一般指程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。而狭义的预处理指程序编译前由预处理器(preprocessor)对程序源代码文本进行处理的过程。预处理是以预处理指令(preprocessing directive)来实现的,预处理器会根据字符#开头的预处理指令修改原始C程序,修改后的C程序之后再进行编译等过程。
作用:C/C++的预处理器支持多种预处理指令,主要包括#if(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令),预处理可以使源代码在不同的执行环境中被方便地修改、移植和调试,也有利于阅读和模块化程序设计。
在Ubuntu下,预处理的命令为:gcc -E hello.c -o hello.i
其中gcc指使用gcc编译器,-E参数指对文件进行预处理操作,-o选项用来指定输出文件为hello.i。最后就生成了hello.i文件。
具体命令执行和生成文件如图2所示。
hello.i是预处理后的文本文件,行数从处理前的23行扩展到了3091行,主要原因是执行了#incluede的预处理指令,解析并引入stdio.h、unistd.h、stdlib.h等头文件,同时也会执行这些引入头文件中的预处理指令,如#if、#define、#line等,最终得到了最终预处理完的文件,可以发现原有的hello.c的代码在hello.i的末尾。
本章首先从理论介绍了预处理这一过程的概念及作用,并在Linux系统上以hello.c文件为例进行了预处理过程得到hello.i,并对相关结果进行了分析。
概念:编译是指编译器将经过预处理的文件进行分析优化,并生成汇编语言程序的过程,生成的汇编语言程序之后用于链接等过程。
作用:编译过程可以将不同的高级语言程序都转化为汇编语言程序,这有利于程序在不同设备平台上的移植。
在Ubuntu下,编译的命令为:gcc -S hello.i -o hello.s
其中gcc指使用gcc编译器,-S参数指对文件进行编译操作,-o选项用来指定输出文件为hello.s。最后就生成了hello.s文件。
具体命令执行和生成文件如图5所示。
hello.s最开始是记录文件信息的部分,如图6所示。
其中.file指明了源文件,.text代表该部分为代码节,.section .rodata代表只读数据,在这里具体就是printf语句中的格式串,之后紧跟的.string代表字符串。.align 指出对齐方式为8字节对齐。.globl代表main是一个全局变量。.type表明main是一个函数类型。
局部变量会储存在栈中,如图7所示。当进入main函数后,先声明了一个int类型的局部变量,但没有初始化。而在汇编语言中将栈指针%rbp减去4,相当于申请了一段空间来储存局部变量i,当局部变量所在的函数运行结束后,其所在的空间就会被释放。
字符串常量在前文已经见过,它们在hello.s最开头.rodata的LC0与LC1处已被储存,且标记为只读数据,当函数中使用到这些字符串常量时,会得到字符串对应的地址使用,防止字符串数据被修改。
赋值操作通过数据传送指令来进行,主要是MOV类指令,如movb,movw,movl,movq,例子如图9所示。
算术操作大多通过专门的指令来实现,比如加1可用INC指令实现,但本汇编程序中,i+1直接用加法指令add实现。其余的还有减1指令dec,减法指令sub,乘法指令imul,异或指令xor,或指令or,与指令and等。
关系操作主要通过CMP指令与一些其他指令结合而实现,CMP指令与SUB指令行为一样,但只根据两数之差设置条件码而不更新寄存器。在本汇编程序中,几处关系操作如图11所示,“!=”条件判断通过cmpl+je实现, “<”条件判断通过cmpl+jle实现。
对于数组的访问都是通过首地址+偏移量的方式实现的。在本汇编程序中三次访问数组元素,过程都是先将-32(%rbp),即数组的首地址传入寄存器%rax中,之后根据要访问的元素加上一定的偏移量。如访问argv[1]时就加上偏移量8,访问argv[2]时就加上偏移量16,访问argv[3]时就加上偏移量24。
程序中的控制转移主要是指条件、循环等控制程序执行顺序方式的操作。在汇编语言中,主要是通过关系操作、条件跳转等指令结合形成的。例如在本汇编程序中if条件语句通过cmpl+je实现,若argc=4,就跳转到L4,若argc不等于4就执行橙色方框中的语句。而for循环语句也是如此,在每次执行完循环内的内容后将变量i增加1,然后将i与4比较,当i<=4时跳转到L4再次执行循环内容。若i>5就跳出循环执行后面的语句,具体如图13所示。
调用函数时会用到指令call,这时会把当前指令运行的地址压入栈中,当调用的函数执行完后弹出当前地址,继续下面的程序。函数调用时可能会传递参数,一般情况下用寄存器传递参数,依次使用%rdi,%rsi,%rdx,%rcx,%r8,%r9传递第一到第六个参数,更多的参数会通过栈进行传递。而函数返回时返回值储存在寄存器%rax中。
以本汇编程序为例,当第二次调用printf函数时,字符串首地址、argv[1]、argv[2]分别作为第一、二、三个参数,依次储存在%rdi,%rsi,%rdx寄存器传递给printf函数。另外当调用完atoi函数后,函数返回值储存在%rax中,之后又传递给%rdi寄存器作为传递给sleep函数的参数。具体过程如图14所示。
本章首先从理论上阐述了编译的概念和作用。之后以得到的hello.s程序为例,详细分析了编译器如何处理C语言的各种数据和操作,并对汇编程序进行了详细分析。
概念:汇编过程将把编译得到的汇编程序指令逐条翻译为机器语言指令,然后把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在一个二进制文件中。
作用:汇编将汇编指令转换成一条条机器可以直接读取分析的机器指令,计算机只有通过机器指令才能实现各运算过程。
在Ubuntu下,编译的命令为:gcc -c hello.s -o hello.o
其中gcc指使用gcc编译器,-c参数指对文件进行汇编操作但不链接,-o选项用来指定输出文件为hello.o。最后就生成了hello.o文件。
具体命令执行和生成文件如图15所示。
通过指令:readelf -a hello.o > hello_o.elf就可以得到hello_o.elf文件,一个经典的ELF可重定位目标文件格式如图16所示,主要包括ELF头、节头部表、.text节等。
ELF头描述了文件的总体格式。它以一个16字节的序列开始,该序列描述了生成该文件的系统的字的大小和字节顺序,剩下部分包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等,这些部分可以帮助链接器语法分析和解释目标文件的信息。
节头部表是描述目标文件的节,它描述了不同节的名字、类型、位置和大小。
.rel.text表示可重定位代码,它是一个.text节中位置的列表,当后续链接器把这个目标文件和其它文件组合时,需要修改这些位置。而.symtab表示符号表,它存放在程序中定义和引用的函数和全局变量的信息。hello.elf中的可重定位节和符号表如图19所示。
对比hello.o的反汇编文件hello_o_asm.txt和hello.s,可以发现两者在汇编指令上无太大差别,但反汇编文件有机器指令,由操作码和地址码构成,每条机器指令最前面有指令的相对地址。
在hello.s文件中,分支转移的目的地是使用段名标出的,而经过汇编后分支跳转有了具体确定的地址偏移。
和分支转移一样,在hello.s文件中,函数调用的具体目的地是用函数名给出的,而在汇编之后,函数调用有了具体的地址,是该条命令的下一条命令的地址。但这显然不正确,因为调用的函数还在其它库中,其具体调用的地址无法确定,只有在经过链接后函数调用的准确位置才能确定。具体情况如图22所示。
本章首先从理论上阐述了汇编的概念和作用。之后以hello.o程序为例,详细分析了汇编前后程序的变化。可看出汇编后产生了可重定位目标文件,这其中有可供计算机理解的机器代码,同时,之前很多不确定的函数调用和分支转移有了准确的地址偏移,以便于接下来链接器进行的链接过程。
概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时、加载时、运行时,它是由链接器的程序自动执行的。
作用:链接过程使得分离编译(separate compilation)成为可能,它使得程序员编程时不必将大型的应用程序组织为一个巨大的源文件,而是可以将其分解为更小、更好管理的模块,可以独立地修改和编译这些模块,当修改其中一个模块时,只需要重新编译该模块并重新链接应用,而不必编译其它文件,链接对模块化编程起到了重要的作用。
在Ubuntu下,编译的命令为:
ld -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/gcc/x86_64-linux-gnu/11/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/11/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
在终端输入完链接命令后就得到了可执行目标文件hello。
具体命令和生成文件如图23所示。
通过指令:readelf -a hello > hello.elf就可以得到hello.elf文件,一个经典的ELF可执行目标文件格式如图24所示,与可重定位目标文件格式相似包括ELF头、节头部表,不过还多出来段头部表、.init节等。
ELF头中如图25所示,记录了文件的类型,版本信息,段表文件偏移、节表文件偏移,程序入口点等信息。可以发现图25中类型一项为EXEC(可执行文件),这与前文的hello.o文件不同。
节头部表如图26所示,主要描述目标文件的节,它描述了不同节的名字、类型、位置和大小等信息,可以发现hello节头部表较hello.o节头部表增加了不少节和相关节的信息。
程序头部表,也称段头部表,如图27所示,它描述了可执行文件的连续片与连续的内存段之间的映射关系,主要包括所指向段的类型、其在ELF文件中的偏移地址、物理地址、映射到内存的虚拟地址、段的读写权限、对齐方式等。
可使用edb加载hello,edb中的Data Dump显示了hello的虚拟空间地址。
从图28可以看到hello虚拟空间从0x401000开始载入,且是.init的地址,通过对比程序头部表可以发现0x401000正是程序载入地址,且查阅节头部表发现0x401000确实是.init的地址。
同理,从节头部表中还可以看见.text节的起始地址为0x4010f0,这也是ELF头中显示的程序的入口点地址,通过与edb上hello的虚拟地址空间对照可以发现0x4010f0处有一个_start函数,这是程序真正的入口点,这是hello.o在链接后加上去的部分。
最后,从节头部表可以发现.rodata节的起始地址为0x402000,这不属于程序的代码部分,而是一个只读数据,按前文编译中介绍的,该只读数据里存有两个printf语句中用到的格式串。用edb查看该地址发现确实存有这两个字符串,如图30所示,虽然无法看到具体字符串内容,但可发现第一个字符串有的“hello”。
使用命令:objdump –d hello > hello_asm.txt就可得到hello的反汇编文件。对比hello和hello.o可以发现许多不同,如借此可以看出链接的过程。
从图31中首先可以发现hello.o只有.text节中的代码,且只有main函数的指令,另外指令所有的地址都是相对main函数的偏移地址。而hello有.init、.plt、.text等多个节的代码指令,而地址是映射到虚拟内存空间中的地址。
聚焦于main函数代码,可以发现之前hello.o中占位的地址变成了实际的地址,主要有三类:分支跳转、函数调用和只读数据访问,具体如图32所示,分支跳转用红框标出,函数调用用蓝框标出,只读数据访问用黄框标出。
由此可以看出链接过程主要分为两步,分别是符号解析和重定位。符号解析目的是将每个符号引用与一个符号定义关联起来,重定位目的是将每个符号定义与一个内存地址关联起来,从而重定位节。
这里重定位过程以main函数里引用的函数printf为例。
图33右上方是hello.o中调用printf函数命令,左上方是hello中调用printf函数命令。可以看出未重定位前call命令的地址码为0占位。hello中call命令的地址是0x401239,执行call指令时PC的值为下一条指令的地址0x40123e,而printf函数地址为0x4010a0,两者之差为0xfffffe62,因为Linux里用的是小端法,因此call指令的地址码应为62feffff,这正是图33左上方hello中call指令的地址码,这样就实现了重定位的过程。
子程序名 | 程序地址 |
_start | 0x00000000004010f0 |
__libc_start_main | 0x00007fa82ac29dc0 |
__cxa_atexit | 0x00007f57014458c0 |
_init | 0x0000000000401000 |
puts@plt | 0x0000000000401090 |
printf@plt | 0x00000000004010a0 |
getchar@plt | 0x00000000004010b0 |
atoi@plt | 0x00000000004010c0 |
exit@plt | 0x00000000004010d0 |
sleep@plt | 0x00000000004010e0 |
deregister_tm_clones | 0x0000000000401130 |
_dl_relocate_static_pie | 0x0000000000401120 |
register_tm_clones | 0x0000000000401160 |
__do_global_dtors_aux | 0x00000000004011a0 |
frame_dummy | 0x00000000004011d0 |
_fini | 0x0000000000401270 |
在hello程序中可以发现调用共享库的函数名称后面都加了“@plt”,通过观察反汇编文件,可以发现使用这些函数时都会跳转到.plt节中,如图34所示,hello程序调用了6个共享库的函数,.plt节中就有6处跳转指令,它们跳转到了同一个地址0x401020,然后在0x401026处它们会进行一个间接跳转,跳转到<_GLOBAL_OFFSET_TABLE_+0x10>处,及0x404120内存中存放的地址处。
而这个地址在dl_init前,这个值如图35左侧所示,是一个空值,而当调用一个动态链接库中的函数时,该地址内容值会发生变化,使其跳转到所调用的对应的函数地址处。
本章首先介绍了链接的概念和作用,之后以hello.o为例链接生成了hello。并借此对可执行目标文件及其虚拟地址空间进行了介绍,最后对链接过程特别是重定位过程和动态链接过程进行了实例分析,使得对链接的本质有了更好的了解。
概念:进程的定义是计算机一个执行中程序的实例,也称为是计算机中的程序关于某数据集合上的一次运行活动。它是系统进行资源分配的基本单位,是操作系统结构的基础,系统中的每个程序都运行在某个进程的上下文中。
作用:进程是对正在运行的程序过程的抽象,进程可以抽象地提供给应用程序一个独立的逻辑控制流和一个私有的地址空间。它可以清晰地刻画动态系统的内在规律,有效管理和调度进入计算机系统主存储器运行的程序。
作用:Shell是一种命令行解释器, 其读取用户输入的字符串命令, 解释并且执行命令. 它是一种特殊的应用程序, 介于系统调用/库与应用程序之间, 其提供了运行其他程序的的接口.它可以是交互式的, 即读取用户输入的字符串;也可以是非交互式的, 即读取脚本文件并解释执行, 直至文件结束。
处理流程:当Shell接受一条命令后,他会首先解析命令行中的命令及相关参数,如果命令是要执行一个程序,它会首先执行fork函数创建一个新的子进程,然后在这个子进程中执行execve函数在加载并运行这个程序,之后就会在前台进程中执行waitpid函数等待子进程中程序执行完毕后回收该子进程。如果命令是一个内置的shell命令,它就直接执行该命令。
当父进程调用fork函数可创建一个新的子进程。新创建的子进程得到与父进程用户级虚拟地址空间相同(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本。父进程与子进程之间最大的区别在于它们有不同的PID,父进程中fork返回子进程的PID,子进程的PID总是非0,而子进程中fork返回0。
以运行hello程序为例,当输入./hello时,父进程为shell,它会对这条命令进行解析,因为这不是内置shell命令,它判定要执行hello这个可执行文件,于是它就调用fork函数创建一个新的子进程以便接下来将hello加载到这个进程中执行。
execve函数声明为:int execve(char *filename,char *argv[],char *envp[])
execve函数会在当前进程的上下文中加载并运行一个新程序,它加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp,在execve加载filename时会调用一个加载器,加载器会创建内存映像并将可执行文件的片(chunk)复制到代码段和数据段,接下来,加载器会跳转到程序的入口,即_start函数的地址点来设置用户栈,如图36所示。初始化程序后就会把控制权传递给main函数。
以运行hello程序为例,当shell调用fork函数创建一个新的子进程后,它会调用execve函数加载并运行可执行目标文件hello,如果命令./hello后跟有参数,shell也会把这些参数当作参数列表argv一起传入进程,这样hello就实现了由程序到进程的转变,之后完成初始化程序后,就会正式运行main函数。
一个进程和其它进程轮流进行的概念称为多任务,一个进程执行它的控制流的一部分的每一时间段叫做该进程的时间片,操作系统内核使用一种称为上下文切换的异常控制流实现多任务,具体如图37所示。
内核为每个进程维持一个上下文,系统中的每个程序都运行在某个进程的上下文中,进程上下文信息就是内核重新启动一个被抢占的进程所需的状态,它由一些对象的值组成,包括寄存器、程序计数器、用户栈等。
以执行程序hello中的sleep函数为例,当main函数执行了sleep系统调用函数时,触发了陷阱异常,此时从用户模式切换为内核模式,main所在进程休眠一段时间,控制权交给其它进程,执行了上下文切换,此时会切换回用户模式。当休眠结束时,会发送信号给内核,此时又会进入异常处理程序,又从用户模式切换为内核模式,它会执行从其它进程到main所在进程的上下文切换,结果控制权又交回给main所在进程。调用其它系统函数时也会有类似的进程调度过程。
异常可以分为四类:中断、陷阱、故障和终止,具体区别如图38所示。
中断是异步发生的,是来自处理器外部的I/O设备的信号结果,它是异步的,总是将控制返回到下一条指令。
陷阱是有意的异常,陷阱最重要用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。陷阱处理程序总是将控制返回到下一条指令。
故障由错误情况引起,它可能被故障处理程序修正,如果处理程序能修正,它将控制返回到引起故障的指令重新执行它,否则返回到内核中的abort例程,这会终止引起故障的应用程序。
终止是不可恢复的致命错误造成的结果,终止处理程序从不将控制返回。
正常执行hello程序时,在hello进程结束后,hello进程会变为僵死进程并向父进程Shell发送SIGCHLD信号,父进程Shell会完成hello进程的回收工作。
(1)运行时乱按或输入回车
当按程序要求输入学号+姓名+秒数的参数后启动程序,如图39所示在程序执行过程中乱按或输入回车。可以发现在hello执行过程中,按回车会正常换行,且引起中断,但因为没有对应的中断处理程序,因此回车指令不会对程序起什么影响。但是乱按输入命令时只会显示在终端上,并不会在程序运行过程中处理乱按的指令,在程序结束后,Shell会依次解析运行指令。
(2)运行时输入Ctrl-C
程序运行时输入Ctrl-C会导致进程立即终止,因为Ctrl-C会发送一个SIGINT信号给hello进程,而SIGINT信号的默认操作是终止进程,因此终止处理程序会立即终止进程。
(3)运行时输入Ctrl-Z
程序运行时会使进程暂停,能输入并执行其它的命令。因为输入Ctrl-Z时会发送一个SIGTSTP信号给hello进程,而SIGTSTP信号的默认操作是暂停当前进程,这使得正在执行的hello进程暂停挂起,可以输入执行其它命令。
因此,输入Ctrl-Z后可以输入一些其它命令,具体输入命令如图41所示。
输入ps命令可以监视后台其它的进程。
输入jobs命令可显示当前shell环境中已启动的作业状态。
输入fg命令,会向挂起的的hello进程收到SIGCONT信号,从而切换到前台继续运行。
输入pstree命令,会将所有进程以树状图显示,具体情况如图42所示。
当输入kill -9 48903命令表示给PID为48903的hello进程发送9号信号SIGKILL,它的默认操作是终止进程,于是hello进程会被终止。
本章首先从理论上介绍了进程的概念和作用,然后以hello程序为例,具体展现了hello由程序一步步到进程中间经历的过程。另外本章还具体演示了一些hello在Shell中执行时的异常及其信号处理,使得对进程管理有了更深刻的理解。
逻辑地址:逻辑地址是程序经过编译后出现在汇编代码中的地址,用来指定一个操作数或者是一条指令的地址。它由一个段标识符(Segment Selector)加上一个指定段内相对地址的偏移量(offset)组成的,段标识符可以是代码段或者数据段等,而offset就是指令对应的段内偏移。比如hello中的main函数就在代码段,同时与这个代码段的段首有一定的偏移,它对应的逻辑地址就由代码段的段标识符和段内偏移量两部分组成。
虚拟地址:我们理解的虚拟地址其实就是逻辑地址的段内偏移offset。比如main函数的虚拟地址即代码段的段内偏移量。
线性地址:线性地址是逻辑地址向物理地址转化中间过程地址,逻辑地址经过段式内存管理后转化为线性地址,其中段标识符转化为段基地址(segment base address),段内偏移不变。而线性地址就由段基地址和段内偏移两部分组成。
物理地址:物理地址是指出现CPU外部地址总线上的寻址物理内存的地址信号,它由线性地址通过页式内存管理转化而成,是地址变换的最终结果地址。比如hello程序只读数据段的两个字符串在内存中实际存在的地址就为物理地址。
正如7.1介绍逻辑地址的形式为[Segment Selector:offset],其中Segment Selector为段标识符,存储于段寄存器,offset是段内偏移量。逻辑地址到线性地址段式内存管理的大致过程如下:
首先,由段标识符可以通过全局描述符表(GDT)或局部描述符表(LDT)获得段描述符(segment descriptor)。
之后,从段描述符中可以得到段基地址(segment base address)。
最后,段基地址与段内偏移两部分可以构成线性地址。
线性地址的形式即为[segment base address:offset]。
详细的逻辑地址到线性地址的变换-段式管理过程如图43所示。
线性地址的形式为[segment base address:offset],即段基地址与段内偏移结合。
页式内存管理实现了从线性地址到物理地址的映射,它把线性地址空间和物理地址空间分别划分为大小相同的块。这样的块称之为页。通过在线性地址空间的页与物理地址空间的页之间建立映射,实现线性地址到物理地址的转换。
具体过程如图44所示,首先线性地址在后可以分成虚拟页号(VPN)和虚拟页偏移量(VPO)两部分,而物理地址经过分页管理也分成物理页号(PPN)和物理页偏移量(PPO)两部分。其中MMU可以利用VPN找到对应的PPN,而VPO和PPO是相同的。故将找到的PPN与PPO结合就得到了物理地址。
翻译后备缓冲器(TLB)是一个小型虚拟寻址的缓存,其中保存着由单个页表条目(PTE)组成的块,在TLB中用于组选择和行匹配的索引和标记字段由虚拟地址的VPN提取,VPN可以分为TLB标记(TLBT)和TLB索引(TLBI)两部分,通过它们有可能获取到对应的物理页号PPN从而快速得到物理地址。
多级页表可以解决页表占用内存过多的问题,对于4级页表而言具体方案是将虚拟地址分为4个VPN和1个VPO,每个VPN都是到对应级的页表的索引,而每一级页表中的每个PTE都会指向下一级的某个页表的基址(第4级除外),而第4级页表中的每个PTE都包含某个物理页的PPN或者一个磁盘块的地址。根据VPN依次访问第1-4级页表就可以找到对应的PPN,找到对应物理地址。
TLB与四级页表支持下的VA到PA的变换如图45所示,首先将虚拟地址(VA)分为VPN和VPO,VPN可以分为TLBT和TLBI,通过这两项可以访问TLB,如果TLB命中,则可以直接找到PPN,从而得到物理地址(PA),如果TLB不命中,则通过依次访问四级页表找到对应的PPN,实现由VA到PA的转换。
虚拟地址翻译得到物理地址后就会进行物理内存的访问。目前常见的CPU都设置有三级cache,其中一级cache分为指令高速缓存和数据高速缓存,而三级cache是所有核共享的。因为目前主存与cache一般用组相联映射,故物理地址会分为三个部分,其中PPN即为缓存标记CT,而PPO又会分为缓存组索引CI和缓存偏移CO。
如图46所示,物理地址首先会访问L1 cache,如果命中就访存成功,如果没命中会再访问L2 cache,如果还没命中则会访问L3 cache,如果三级cache都没命中则会直接访问主存,如果没有缺页则访存成功,如果发生缺页会调用缺页异常处理程序把数据从硬盘调入主存再次访问主存。
在Shell中输入./hello时,Shell会调用fork函数创建一个新的子进程,内核会为新的子进程创建各种数据结构,并分配给它一个唯一的PID。为了给新的子进程创建虚拟内存,内核会创建当前进程的mm_struct、区域结构(vm_area_struct)的链表和页表的原样副本,它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存,随后的写操作通过写时复制机制创建新页面。示意图如图47所示。
运行hello程序在调用fork函数创建新子进程及对应的虚拟内存后,内核会调用execve函数在当前子进程中加载并运行包含在hello.out中的程序,execve加载并运行hello.out需要几个步骤,内存映射示意如图48所示。
首先会删除当前进程虚拟地址用户部分已存在的区域结构。之后会为hello.out的代码、数据、bss和栈区域创建新的区域结构,这些区域都是私有的、写时复制的,代码和数据区域会被映射为hello.out文件中的.data和.text区。接着会映射与hello.out链接的共享对象库,比如标准C库libc.so,会将它们映射到用户虚拟地址空间中的共享区域。最后会设置程序计数器PC,使之指向代码区域入口点。
DRAM缓存不命中称为缺页(page fault)。程序运行时,不是所有数据都加载到了内存中,如果程序运行需要的数据未从磁盘上加载进入内存中,即当从cache或内存传回MMU的页表条目PTE的有效位为0时,就会引发缺页故障异常。
缺页中断处理过程如图49所示。当发生缺页故障时,MMU触发了异常,控制权交给内核中的缺页异常处理程序,程序会首先确定物理内存中的牺牲页,如果这个页面已被修改,则把它换出到硬盘。接着处理程序会从硬盘中调入新的所需页面,并更新内存中的PTE。最后缺页处理程序返回越来的进程,再次执行导致缺页的指令,CPU会将引起缺页的虚拟地址重新发给MMU,因为缺少的页已调入主存,则不会再触发缺页故障异常。
通过动态内存分配器可实现动态储存分配管理,它维护一个进程的虚拟内存区域——堆。分配器将堆视为一组不同大小块的集合来维护,每个块就是一个连续的虚拟内存片(chunk),它要么是已分配的,要么是空闲的。已分配的块供应用程序使用,空闲的块可用来分配给程序。动态存储分配管理的过程就是块的分配和释放,这要么是程序显式执行的,要么是动态内存分配器隐式执行的。
分配器有两种,分别是显式分配器和隐式分配器,它们都要求应用显式地分配块,它们的不同之处在于由哪个实体来负责释放已分配的快。
显式分配器(explicit allocator)要求应用显式地释放任何已分配的块,例如C标准库提供malloc程序包的显式分配器。C程序可通过调用malloc函数分配一个块,调用free函数释放一个块。显式分配器常用一种叫做隐式空闲链表的数据结构来实现,如图50所示。另外还会使用显式空闲链表、分离的空闲链表等。
隐式分配器(implicit allocator)也叫垃圾收集器,它可检测当一个分配块不再被程序调用时,就释放这个块。自动释放未使用的已分配块的过程叫做垃圾收集。隐式分配器将内存视为一张有向可达图,当有节点不可达时,将该节点对应为垃圾。具体过程如图51所示。
本章主要介绍了hello有关的存储管理。首先介绍了hello程序中指令的虚拟地址如何从逻辑地址通过段式管理转化到线性地址,再通过页式管理转化到物理地址的。之后介绍了虚拟地址到物理地址整体转化过程和物理地址访存过程。接着讲述了hello进程调用fork和execve时内存映射情况。最后介绍了hello程序如何处理缺页故障异常和实现动态存储分配管理。
设备的模型化:文件
设备管理:Unix I/O接口
一个Linux文件就是一个m个字节的序列:B0,B1,…,Bk,…,Bm-1。
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对对应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
Unix I/O使所有的输入输出都以一种统一的方式进行。
首先是打开文件。应用程序会通过要求内核打开对应的文件,来宣告它想要访问一个I/O设备,而内核会返回一个描述符,用来在后续操作中标识这个文件。
之后是改变当前的文件位置。对于每个打开的文件,内核保持一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量。
接着是读写文件。一个读操作就是从文件文件复制n > 0个字节到内存,从当前文件位置k开始,然后将k增加到k + n。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
最后是关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述池中。
open函数定义为:int open(char *filename,int flags,mode_t mode)。
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。Flags参数指明了进程打算如何访问这个文件。mode参数指定了新文件的访问权限位。
close函数的定义为:int close(int fd)。
close函数将描述符为fd的文件关闭。成功的时候返回0,出错时返回-1。
read函数的定义:ssize_t read(int fd,void *buf,size_t n)。
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。若成功返回读的字节数,若EOF返回0,若出错返回-1。
write函数的定义:ssize_t write(int fd,const void *buf,size_t n)。
从内存位置buf复制至多n个字节到描述符fd的当前位置。若成功则为写的字节数,若出错则为-1。
lseek函数的定义:off_t lseek(int fd,off_t offset,int whence)。
lseek函数可以修改文件的字节偏移量。
printf函数的函数体如图52所示。
首先printf函数输入的参数中含有“…”,这代表可变形参,即传递参数的个数不确定。
之后va_list arg = (va_list)((char*)(&fmt) + 4)中va_list为一个定义的字符指针类型,arg即为一个字符指针,而fmt是一个指针,这个指针指向第一个const参数中的第一个元素。那么清楚arg即为printf可变形参中的第一个参数的地址。
vsprintf的定义为:int vsprintf(char *buf,const char *fmt,va_list args)。这命令的目的是将从第一个传递参数开始,依次把这个字符串传入buf缓冲区中,其中返回值为i即是要打印出来的字符串的长度。
最后write函数就会把buf中的i个元素的值写到终端。
但从硬件上看,从vsprintf到最终显示器上显示出字符串还有对应漫长过程:
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等。接着字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。最后显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
getchar函数的函数体如图53所示。
当用键盘输入字符串时,会触发键盘中断异常,执行键盘中断处理子程序。字符接受按键扫描码转化成ASCII码,保存到系统的键盘缓冲区,getchar调用read函数,将键盘缓冲区中的字符读入buf中,并测得该字符串的长度为n,然后令字符指针bb指向buf。最后返回buf中第一个字符。如果长度n<0,则会报EOF错误。
本章首先介绍了Linux的I/O设备管理方法和Unix I/O接口及其相关函数,之后以printf函数和getchar函数为例介绍其执行流程。
当程序员用键盘敲击输入C语言命令保存在磁盘上以.c文件存储时,hello.c文件产生了,之后它经历了漫长的一生。
首先hello.c经过预处理过程,执行了若干预处理指令,包括引用头文件,展开宏等,得到了hello.i文件。
接着hello.i文件经过编译器,C语言程序通过编译转化为汇编语言程序hello.s。
之后hello.s文件经过汇编器,汇编过程将把汇编程序指令逐条翻译为机器语言指令,然后打包成可重定位目标程序,结果保存在二进制文件hello.o中。
最后hello.o文件经过链接器,hello.o与许多其他需要调用的库函数经过动态链接生成可执行目标文件hello(hello.out)。
至此,hello完成了从C语言文件到可执行目标文件的第一次跃变。之后hello要完成从程序到进程的第二次跃变。
首先,当在Shell中输入./hello命令时,Shell解析输入的命令,之后调用fork函数生成新的子进程,接着调用execve函数将hello.out程序加载入内存并为其分配动态内存。hello加载进入内存之后,首先进行动态链接,动态链接器根据hello的需要构建一个查表函数,而hello通过这个查表函数来进行对共享库函数的调用。
hello程序正式执行时,它通过进程管理安排执行的逻辑控制流。当执行指令时,CPU发送的逻辑地址通过段式管理会变为线性地址,MMU通过TLB和页表将线性地址变为物理地址,再在三级Cache的帮助下用物理地址进行访存。
当然,在hello程序执行过程中,可能会接受I/O设备的信号,并触发异常,这时程序会调用异常处理程序处理异常。当hello要输入输出内容时,它要通过以文件形式管理的I/O设备,调用相关函数。
最后,当hello程序执行结束时,hello进程会变为僵死进程,此时已调用waitpid函数的Shell父进程会回收hello进程,并删除该进程的一切。hello程序正式完成了它的一生。
通过探索hello程序的一生,我详细了解了程序从无到有的处理和执行过程,并借助这些过程对计算机系统的相关知识有了更深刻的知识。比如汇编语言和C语言如何对应、程序的动态链接、虚拟内存的管理、执行程序时的逻辑控制流、信号的获取以及异常的处理等等。
同时,我也对计算机有了更全面的认识,我们以前认为平凡无奇的hello程序,在计算机里竟然能有如此跌宕起伏的一生,让人感慨。而这门课将计算机的软件和硬件相结合,让人收获颇多,也激励了以后我对计算机进一步的探索。
文件名 | 文件描述 |
hello.c | 源程序 |
hello.i | hello.c预处理后的预编译处理文件 |
hello.s | hello.i编译后的汇编文件 |
hello.o | hello.s汇编后的可重定位目标文件 |
hello | hello.o链接后的可执行目标文件 |
hello_o_asm.txt | hello.o反汇编后文件 |
hello_asm.txt | hello反汇编后文件 |
hello_o.elf | hello.o的ELF格式文件 |
hello.elf | hello的ELF格式文件 |
printf.txt | print函数的函数体 |
getchar.txt | getchar函数的函数体 |
[1] 《深入理解计算机系统》Randal E. Bryant David R.O`Hallaron
[2] 预处理—百度百科
[3] 进程—百度百科
[4] Shell简介:Bash的功能与解释过程(一) Shell简介
[5] linux内核中逻辑地址、虚拟地址、线性地址和物理地址大扫盲
[7] printf函数实现的深入剖析.
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。