【程序人生】哈工大2022春计算机系统大作业

摘要

本文通过剖析一个hello.c程序在linux系统下的预处理、编译、汇编、链接过程以及生成可执行文件hello之后,操作系统为它的运行进行进程管理、内存管理、I/O管理的全过程,解读了一个程序的“一生”,并以此总结和梳理了深入理解计算机系统课程的知识体系和结构,深化了对计算机系统的理解。

关键词:Linux;预处理;编译;汇编;链接;进程;存储管理;IO管理

第一章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

在Linux下,从一个program即Hello.c出发,先经过预处理器cpp的预处理得到了Hello.i(修改了的源程序),再通过编译器ccl编译获得汇编程序Hello.s,由汇编器as处理得到可重定位目标程序Hello.o,最后由链接器ld进行链接的步骤得到了一个可执行目标程序Hello。在shell中,输入./Hello运行程序,shell会先fork一个子进程,调用execve,映射虚拟内存并通过mmap分配空间,这样原来的Hello程序就成为了一个进程。CPU为它分配时间片以执行逻辑控制流,cache、TLB等为它的运行加速,IO管理与信号处理使它在设备上得以正确显示和能够正确处理异常。在程序运行结束后,shell将对hello及其创建的子进程进行回收,内核彻底删除其留下的痕迹例如数据等等,它又回到了“0”。

1.2 环境与工具

硬件环境:AMD Ryzen 7 Mobile 4800U;1.80GHz;16.0G RAM;512GHD Disk

软件环境:Windows10 64位;VirtualBox 11;Ubuntu 20.04.4 LTS 64位;

开发工具:CodeBlocks 64位;vi/vim/gedit+gcc;edb

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

Hello.i 经过预处理后的文本文件

Hello.s 经过编译后的汇编语言源程序文件

Hello.o 二进制的机器代码的可重定位目标文件hello.o

Hello 经过链接后的可执行文件hello

1.4 本章小结

       本章总体介绍了Hello实现“P2P,020”的过程,列出了研究的环境和使用的工具等等。

第二章 预处理

2.1 预处理的概念与作用

预处理是在对程序进行编译之前的处理步骤,预处理过程扫描源代码,根据以字符“#”开头的命令对其进行初步的转换,产生新的源代码提供给编译器。预处理过程读入源代码,检查包含预处理指令的语句和宏定义,并对源代码进行响应的转换。预处理过程还会删除程序中的注释和多余的空白字符。

预处理阶段会将头文件中的定义加入到它所产生的输出文件中,将源程序中的某部分包含进来或排除在外或者进行部分值的替换,为下一步的编译做准备。

2.2在Ubuntu下预处理的命令

Linux中hello.c文件进行预处理的命令是:gcc -E hello.c -o hello.i

图1 预处理hello.c过程

图2 预处理后得到hello.i

2.3 Hello的预处理结果解析

图3 hello.i部分内容

我们发现预处理后得到的文本文件hello.i的内容增加到3060行(原来的hello.c为23行),文件的大小也从527B增加到64.7KB,这是预处理器对hello.c进行了展开宏、头文件,替换条件编译,删除注释、空行和空白这些操作的结果。

2.4 本章小结

本章对预处理环节的概念和作用进行了解释,实际进行了对hello.c的预处理并进行了分析。

第三章 编译

3.1 编译的概念与作用

编译是把经过预处理得到的文本文件,在编译器ccl的作用下生成汇编程序的过程。这个过程将源程序经过词法分析、语法分析、语义分析以及经过一系列优化后生成汇编代码。

它是将高级语言程序转化为机器可直接识别处理执行的的机器码的中间步骤,并且能在分析的过程中检查语法错误并给出提示信息。

3.2 在Ubuntu下编译的命令

命令:gcc -S hello.i -o hello.s

图4 编译结果

3.3 Hello的编译结果解析

3.3.1 常量

       hello.s中将hello.c程序的常量,如字符串常量"用法: Hello 学号 姓名 秒数!\n",存放在只读代码段.rodata。

       hello.c中的另外一些常量,例如8,在汇编程序中直接以立即数的形式出现。汇编语言用$后面加上数字表示立即数。

图5 立即数的例子

3.3.2 局部变量和赋值操作

       程序中的局部变量例如int i将被存放在寄存器或者开辟的栈空间内。

图6 局部变量i的处理

编译器使用mov指令给寄存器或栈的某个地址进行赋值,例如这里就是将0赋值给起始地址为%rbp-4的一处空间,并且通过mov指令的后缀l注明这是4字节的传送,对应源程序中的int。

3.3.3 数据类型的表示

       汇编程序中部分汇编指令带有后缀,这些后缀(b、w、l、q)表示进行这个操作的操作数的大小,这也就反映了在源程序中我们定义的数据类型。

3.3.4 算数操作

       汇编程序中有一系列的算数操作,例如加法有add指令、减法sub指令等等。这样的指令和他们的操作数搭配在一起就实现了源程序中的算数操作。

       以源程序中的i++操作为例,使用add指令,后缀l表示数据大小是int类型,操作数分别是立即数1和i,将i+1后存入i。

图7  i++操作在汇编程序的实现

3.3.5 关系操作和控制转移

       源程序中的一些关系操作例如</!=,在汇编程序中也有相应的实现方法。汇编程序维护条件码,例如ZF/SF/CF/OF等,并对这些条件码进行一些运算来实现关系运算。例如,!=可以通过判断~ZF实现,<可以通过作减法(不改变值)后判断SF^OF实现。

       关系操作通常与条件跳转语句搭配在一起使用,汇编程序通过一系列跳转指令实现条件判断和跳转,例如jmp、jne、jl等。

       以for循环判断条件i<8为例:先通过cmpl指令获取需要的条件码,再用jle语句判断是否需要跳转。

图8 关系操作的实现例子

3.3.6 数组操作

       数组存放在栈上一块连续的空间,对数组元素的表示,在汇编程序中通过加载地址指令leaq、寻址方式等机制共同实现。以数组首地址为基址,使用变址寻址或比例变址寻址方式就可以实现对数组元素的表示,之后对其进行相应的操作即可。

       例如argv[0]存放在-32(%rbp),那么argv[3]就在这个地址+$24的位置。

      

图9 数组操作

3.3.7 指针操作

       汇编程序中实现源程序的指针操作是通过寻址机制和leaq操作实现的。例如argv[0]指针指向的字符串通过寄存器寻址实现。

图10 指针操作

3.3.8 函数操作

       函数是一种过程,过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。对函数的调用包括控制转移、传递数据、分配和释放内存几个部分。

       函数操作的大体流程:函数调用使用call操作,提前把需要的参数存放在寄存器%rdi,%rsi,%rdx,%rcx等等,寄存器不够用会把多的参数存放在栈中。函数调用通过ret返回,若有返回值存放在%rax。

       控制转移:使用call进行过程调用时需要把下一条指令地址存放在栈中以便返回时能够回到这个位置,并且要把PC的值更新为call后面的符号表示的地址。通过ret返回时把PC设置为栈中之前存放的指令地址值即可。

       传递数据:主要是参数的传递和返回值的传递。在上文已经介绍过。

       分配和释放内存:函数调用后通常需要考虑保存寄存器和局部变量,这时需要开辟一块栈空间,返回之前再进行释放。

       hello.c程序中涉及的函数有main、printf、sleep、atoi、exit和getchar。这里以sleep(atoi(argv[3]))为例,我们看到hello.s中将argv[3]处字符串地址放入%rdi作为第一个参数,调用atoi返回后又将返回值从%rax复制到%rdi作为sleep函数调用的参数。

图11 函数调用操作的例子

3.4 本章小结

本章介绍了编译过程的概念和作用,对hello程序进行了编译,并在x86-64指令下以hello.s为例,分析了汇编程序是如何实现源程序中的一些表示和操作例如类型表示、关系操作、控制转移和函数操作的。

经过编译的过程,程序实现了从高级的程序语言向较低级的汇编语言的转换。

第四章 汇编

4.1 汇编的概念与作用

       汇编是指把汇编语言书写的程序翻译成与之等价的机器语言程序的过程。汇编程序输入的是用汇编语言书写的源程序,输出的是用机器语言表示的目标程序。汇编器(as)将.s汇编程序翻译成机器语言并将这些指令打包成可重定目标程序的格式存放在.o目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。汇编过程从汇编程序得到一个可重定位目标文件,以便后续进行链接。

4.2 在Ubuntu下汇编的命令

gcc –c hello.s -o hello.o

图12 汇编结果

4.3 可重定位目标elf格式

分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。

ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。

不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。

夹在ELF头和节头部表之间的都是节,有两个节比较特殊,.rel.text和.rel.data存放的是重定位条目。重定位条目保存的是.text节或全局变量中需要被修正的信息(任何调用外部函数或者引用全局变量的指令都需要被修正),调用外部函数的指令和引用全局变量的指令需要重定位,调用局部函数的指令不需要重定位。

ELF头

.text(已编译程序的机器代码)

.rodata(只读数据)

.data(已初始化的全局和静态变量)

.bss(未初始化或初始化为0的全局和静态变量)

.symtab(符号表)

.rel.text(一个.text节中位置的列表)

.rel.data(被模块引用或定义的所有全局变量的重定位信息)

.debug(一个调试符号表)

.line(源程序行号与.text机器指令的映射)

.strtab(一个字符串表)

节头部表

描述目标文件的节

1 典型的ELF可重定位目标文件的结构

       我们可以使用readelf -a hello.o查看各节的基本信息。

图13 hello.o的ELF头

图14 hello.o的节头部表(部分)

图15 hello.o的重定位信息

       当汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data。

       重定位条目包含以下几个部分:需要被修改的引用的节偏移offset,被修改引用应该指向的符号symbol,类型type表明如何修改新的引用,还可能有偏移调整量addend。

图16 符号表

4.4 Hello.o的结果解析

objdump -d -r hello.o  得到hello.o的反汇编。

图17 反汇编第一部分

图18 反汇编第二部分

图19 hello.s部分内容

通过反汇编结果与编译得到的hello.s的比较,我们发现,这两个程序在大体上十分相似,只有部分细节的处理有所差别。

反汇编得到的结果可以看到机器代码,机器代码由二进制编码构成,机器语言中的指令结构一般由操作码和地址码两部分组成。每一行机器代码都对应了反汇编结果中的一条汇编语句。

通过观察可以发现,在分支跳转时,机器语言对于跳转地址的处理有所不同,它使用相对寻址,即下一条指令的地址加上相对偏移量得到需要跳转的绝对地址,在hello.s中这里是使用了符号名,反汇编结果使用的是偏移处理。另外在函数调用时,hello.s使用函数名这个符号作为标识,而机器代码反汇编得到的程序中每次函数调用都指向下一条语句的地址,说明汇编过程中对代码段进行了顺序的调整,也就是重定位的过程。

4.5 本章小结

本章介绍了汇编的概念和作用,将hello.s汇编处理为hello.o,并以hello.o为例介绍了ELF可重定位目标文件的格式,包括ELF头、节、节头部表,尤其是对重定位条目进行了分析。最后通过反汇编,把反汇编程序与机器代码和hello.s一同进行对照,从另一个角度介绍了汇编和重定位这个过程。

第五章 链接

5.1 链接的概念与作用

       链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是源代码被翻译成机器代码时;也可以执行于加载时,即程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。链接使得分离编译成为可能。我们可以独立的修改和编译我们需要修改的小的模块,而不必将全部的程序都重新编译一次,简化了维护和管理。

5.2 在Ubuntu下链接的命令

       Linux下链接命令: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

图20 链接命令及结果

5.3 可执行目标文件hello的格式

5.3.1 ELF头

包括字大小、字节顺序、文件类型(.o,exec,.so),机器类型,入口点地址等信息。

图21 hello的ELF头

5.3.2 节头部表

       节头部表包含各节的基本信息,包括名称,大小,类型,全体大小,地址,旗标,偏移量,对齐等。这个表注明了节的地址和大小。

图22 hello的节头部表(部分)

5.3.3 程序头

       程序包含PHDR,INTERP,LOAD,DYNAMIC,NOTE,GNU_STACK,GNU_RELRO几个部分,其中PHDR 保存程序头表。INTERP 指定在程序已经从可执行文件映射到内存之后,必须调用的解释器。LOAD 表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等。DYNAMIC 保存了由动态链接器使用的信息。NOTE 保存辅助信息。GNU_STACK:权限标志,用于标志栈是否是可执行。GNU_RELRO:指定在重定位结束之后哪些内存区域是需要设置只读。

注明了页面大小,虚拟地址内存段(节),段大小等信息。在这部分可以看到各段地址和大小。

图23 hello的程序头(部分)

5.3.4 重定位节

图24 hello的重定位节

5.3.5 符号表

图25 hello的符号表

5.4 hello的虚拟地址空间

使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照发现edb显示的各段虚拟地址空间与5.3中的一致。以.init为例,在5.3中可以看到它的起始地址是0x401000,大小为0x1b字节,考虑对齐后实际要占0x20字节,这可以在edb的data dump中找到。

图26 edb中查看虚拟地址空间

5.5 链接的重定位过程分析

图27 hello的反汇编程序(部分)

图28 hello的反汇编程序(部分)

图29 hello的反汇编程序(部分)

图30 hello的反汇编程序(部分)

与hello.o的反汇编程序比较,我们发现这两个程序有显著的不同:hello的反汇编程序中增加了许多函数,这些函数就是程序中引用的外部函数,在链接后被合并到了hello中;程序中还增加了几个节,例如.init、.plt等;另外,两个程序中涉及函数调用和分支跳转的部分也有所不同,hello.o的反汇编程序中这些地方使用的是相对偏移得到地址,而hello反汇编程序中这些地方已经都变成了绝对地址。这些都是通过链接中重定位这一过程实现的。

在链接器完成符号解析的步骤之后,就进入到重定位这一阶段,在这一个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位过程分为两个步骤:

  1. 重定位节和符号定义。链接器会将所有相同类型的节合并为同一类型的新的聚合节作为输出的可执行目标文件的对应类型的节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节和每个符号。这一步完成时,每条指令和全局变量都有唯一的运行时内存地址了。
  2. 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。这一步依赖于.o文件的重定位条目,链接器依据重定位条目中指示的重定位方式(重定位PC相对引用、重定位绝对引用等等)修改引用。

5.6 hello的执行流程

使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。

调用和跳转的各子程序:

<ld-2.31.so!_dl_start>

<ld-2.31.so!_dl_init>

<hello!_start>

<libc-2.31.so!__libc_start_main>

<hello!main>

<hello!printf@plt>

<libc-2.31.so! printf >

<hello!atoi@plt>

<libc-2.31.so! atoi >

<libc-2.31.so! strtoq >

<hello!sleep@plt>

<libc-2.31.so! sleep >

<libc-2.31.so! nanosleep >

<libc-2.31.so! clock_nanosleep >

<libc-2.31.so! _IO_file_xsputn>

<hello!getchar@plt>

<libc-2.31.so!getchar>

5.7 Hello的动态链接分析

分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。

动态链接的基本思想:创建可执行文件时,静态执行一些链接,在程序加载时,动态完成链接过程。

共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在加载或运行时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程被称为动态链接,是由一个叫做动态链接器的程序来执行的。共享库也称为共享目标。

例如hello中的printf、sleep、atoi等都是通过动态链接与源程序建立关系的。

在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。

延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:

PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。

GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。

图31 hello ELF中的.got

根据hello ELF文件可知,GOT起始表位置为0x403ff0。

调用前:.got表位置在调用dl_init之前0x403ff0后的16个字节均为0。

图32 调用前.got状态

调用_start后.got发生了变化,存入了地址。

其中GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,改变后的GOT表如下: ​

图33 调用后.got状态

这样就改变了GOT条目,完成了动态链接。

5.8 本章小结

       本章介绍链接的概念和作用,分析了链接后得到的hello程序的ELF结构,通过hello反汇编程序和hello.o反汇编程序的对照,分析了链接的过程,尤其是重定位过程的步骤和方式。与edb调试器相结合,分析了hello的虚拟地址空间和执行流程以及动态链接的过程。

第六章 hello进程管理

6.1 进程的概念与作用

       进程是一个执行中程序的实例。系统中每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

       进程提供给应用程序两个关键抽象(假象):一个独立的逻辑控制流、一个私有的地址空间。

6.2 简述壳Shell-bash的作用与处理流程

       Shell是一个交互型的应用级程序,它代表用户运行其他程序。Shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。

求值过程调用parseline函数,并构造将会传给execve的argv向量。解析完命令行之后,eval函数调用builtin_command函数检查是否是内置命令。

如果命令行第一个参数是一个shell内置命令名,那么shell会立刻(解释)这个命令,否则shell认为这个参数是一个可执行目标文件的名字,它会在一个新的子进程的上下文中加载并运行这个文件。

如果最后一个参数是&,shell不会等待这个命令完成(在后台执行),否则表示在前台执行,shell会等待它完成。

之后shell会使用waitpid函数等待作业终止并开始下一轮迭代。

6.3 Hello的fork进程创建过程

       Shell调用fork创建子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,因此fork后子进程可以读写父进程中打开的任意文件。父进程和创建的子进程最大的区别在于PID不同。

       fork会被父进程调用一次,返回两次,父进程与创建的子进程并发执行。执行hello时,fork后的进程在前台执行,因此创建它的父进程shell暂时挂起等待hello进程执行完毕。

6.4 Hello的execve过程

       shell fork一个子进程后,execve函数在当前进程的上下文中加载并运行一个新程序(hello)。

       Execve需要三个参数:可执行目标文件名finename、参数列表argv、环境变量列表envp。这些都由shell构造并传递。除非找不到filename,否则execve不会返回。

       调用execve会将这个进程执行的原本的程序完全替换,它会删除已存在的用户区域,包括数据和代码;然后,映射私有区:为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的;之后映射共享区;最后把控制传递给当前的进程的程序入口。

6.5 Hello的进程执行

在计算机中,进程是并发执行的,这涉及一套复杂完备的机制。

在系统中,指令总是一条一条执行着的,这些指令可能并不来自同一个进程的程序,但是这些指令的地址会形成一个执行的序列,这个PC值的序列称为逻辑控制流。处理器的一个物理控制系流会被分成多个逻辑控制流,每个进程一个。这些逻辑流的执行是交错的,也就是每个进程轮流使用处理器,一个进程执行它的控制流的一部分的每一时间段叫做时间片。

进程轮流使用处理器是通过上下文切换、进程调度、用户模式和内核模式这些机制来实现的。

在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策叫做调度,是由内核中称为调度器的代码处理的。内核调度了一个新的进程运行后,它就抢占当前进程,并使用上下文切换机制将控制转移到新的进程,上下文切换(1)保存当前进程的上下文(2)恢复某个先前被抢占的进程被保存的上下文(3)将控制传递给这个新恢复的进程。

上下文切换机制建立低层的异常机制上,需要在内核模式下进行。当设置了模式位时,进程就运行在内核模式中,表示它可以执行指令集中的任何指令并且可以访问系统中的任何内存位置。进程从用户模式变为内核模式的唯一方法是通过中断、故障或陷入系统调用这样的异常。当某个异常发生时,系统进入内核模式,内核可以执行从某个进程A到进程B的上下文切换,在切换的第一部分,内核代表进程A在内核模式下执行指令,在某一时刻开始代表进程B在内核模式下执行指令。切换完成后,内核代表进程B在用户模式下执行指令。这样就实现了多任务。

6.6 hello的异常与信号处理

 程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps  jobs  pstree  fg  kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。

中断异常:来自I/O设备的信号。按下ctrl-c时内核向shell发送SIGINT信号,按下ctrl-z时内核向shell发送SIGTSTP信号,两种情况下控制都会转移到对应的信号处理程序。在SIGINT信号处理程序中,shell向前台进程组发送信号,使前台进程组的所有成员终止;在SIGTSTP信号处理程序中,使前台暂时挂起。

图34 运行hello ctrl-c测试

可以看到在按下ctrl-c后进程终止,这时使用ps命令就看不到hello进程了。

图35 ctrl-z 测试和ps jobs pstree命令运行结果

按下ctrl-z后进程是被暂时挂起,并没有终止,因此使用ps、jobs等还可以看到它。

图36 fg kill命令运行结果

陷阱:程序中sleep函数进行了系统函数调用,会触发异常处理机制,进入内核模式进行处理。

除了中断和陷阱两种异常,还有故障和终止异常,故障通常可以被恢复,例如缺页异常,终止异常会使程序直接终止。

除了发生异常的时候内核会向应用级程序发送信号外,我们可以使用kill命令人为地发送信号给某个进程。这里我向hello进程发送了一个SIGKILL信号,使这个进程终止(杀死程序)。

至于乱按或者回车这些都不会产生什么实际影响,只是把输入的字符放到缓冲区并在屏幕上输出。

6.7本章小结

本章以进程为出发点,先介绍进程的概念和作用,然后分析了shell的作用流程,并且从进程的视角分析了hello的执行的全过程,概括了现代计算机系统实现进程处理的机制,并且以hello为例介绍了一部分异常和信号的相关内容。

 第七章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址(Logical Address)指由程序产生的和段相关的偏移地址部分。hello.c经过汇编生成的偏移地址为逻辑地址。例如,C语言中,读取指针变量本身值(&操作),实际上这个值就是逻辑地址。

线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生段中的偏移地址,加上相应段的基地址就生成了一个线性地址。

虚拟地址(Virtual Address):CPU通过生成虚拟地址访问主存。有时我们也把逻辑地址称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。

物理地址(Physical Address):放在寻址总线上的地址。放在寻址总线上,如果是读,电路根据这个地址每位的值就将相应地址的物理内存中的数据放到数据总线中传输。如果是写,电路根据这个地址每位的值就在相应地址的物理内存中放入数据总线上的内容。物理内存是以字节(8位)为单位编址的。

7.2 Intel逻辑地址到线性地址的变换-段式管理

       机器语言指令中出现的内存地址,都是逻辑地址,需要转换成线性地址,再经过MMU(CPU中的内存管理单元)转换成物理地址才能够被访问到。

    在x86保护模式下,段的信息(段基线性地址、长度、权限等)即段描述符占8个字节,段信息无法直接存放在段寄存器中(段寄存器只有2字节)。段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值(index)。

一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。

全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。

给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset =线性地址。

7.3 Hello的线性地址到物理地址的变换-页式管理

以下格式自行编排,编辑时删除

页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。

这些功能由软硬件联合提供,包括操作系统软件、MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理页时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘和DRAM之间传送页。

形式上来说,hello的线性地址(即虚拟地址)到物理地址的地址翻译是一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)中元素之间的映射:MAP:VAS->PAS∪∅。

7.4 TLB与四级页表支持下的VA到PA的变换

       虚拟内存到物理内存的变换是由地址管理单元MMU来实现的。处理器把一个需要翻译的VA交给MMU,MMU会先到TLB中寻找是否有这个VA对应的页表条目。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,例如VPN 0选择PTE 0。根据查找PTE的信息可以知道这个VA所在的虚拟页的情况,是没有分配、没有缓存还是已缓存。如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。这里的VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。

       在现代计算机中,由于地址空间很大,所以如果只用一个单独的页表来进行地址翻译就需要一个巨大的页表驻留在内存中,为了节省空间,使用层次结构的页表用来压缩页表。以Core i7MMU为例,使用四级页表将虚拟地址翻译成物理地址。36位VPN被划分为四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供一个到L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供一个到L2 PTE的偏移量,以此类推。

7.5 三级Cache支持下的物理内存访问

Cashe的物理访存大致过程如下:

  1. 组选择取出虚拟地址的组索引位,将二进制组索引转化为一个无符号整数,找到相应的组。

2. 行匹配把虚拟地址的标记为拿去和相应的组中所有行的标记位进行比较,当虚拟地址的标记位和高速缓存行的标记位匹配时,而且高速缓存行的有效位是1,则高速缓存命中。

3. 字选择一旦高速缓存命中,我们就知道我们要找的字节在这个块的某个地方。因此块偏移位提供了第一个字节的偏移。把这个字节的内容取出返回给CPU。

4.如果高速缓存不命中,那么需要从存储层次结构中的下一层取出被请求的块,然后将新的块存储在组索引位所指示的组中的一个高速缓存行中。一种简单的 放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突,则采用最近最少使用策略 LFU 进行替换。

在地址翻译的过程中,一部分页表条目也可能缓存在cache中,因此对页表条目的访问也可以使用上述的访存机制。

7.6 hello进程fork时的内存映射

       当fork 函数被 shell 进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID,为了给这个新进程创建虚拟内存,它创建了当前进程mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork 在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

在bash中的进程中执行了如下的execve调用:execve("hello",NULL,NULL);execve函数在当前进程中加载并运行包含在可执行文件hello中的程序,用hello替代了当前bash中的程序。然后删除已存在的用户区域,为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。下一步需要映射共享区域。hello 程序与共享对象 libc.so 链接,libc.so 是动态链 接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。

最后,exceve设置当前进程的上下文中的程序计数器到代码区域的入口点。

7.8 缺页故障与缺页中断处理

       缺页故障是我们之前提到过的一种异常,是可恢复的。DRAM缓存不命中的情况称为缺页。

当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。

缺页中断处理:一个页面就是虚拟内存的一个连续的块。缺页异常调用内核中的缺页异常处理程序,缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中了,指令就可以没有故障地运行完成了。

7.9动态存储分配管理

       动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种基本风格。两种风格都是要求显示的释放分配块。

1.显式分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。

2.隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器。

隐式空闲链表:

带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、可能的额外填充以及一个字的尾部组成的。

在隐式空闲链表中,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块。

当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配合最佳适配。分配完后可以分割空闲块减少内部碎片。同时分配器在面对释放一个已分配块时,可以合并空闲块,其中便利用隐式空闲链表的边界标记来进行合并。

显式空闲链表:

显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。

在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。

7.10本章小结

       本章主要以hello进程为例介绍程序存储管理。分析了逻辑地址、线性地址、虚拟地址、物理地址的区别和联系以及它们的转换。分析了实现虚拟地址向物理地址映射的机制和hello进程fork、execve时的内存映射。

第八章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件。所有的I/O设备都被模型化为文件,甚至内核也被映射为文件。

设备管理:unix io接口。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。

我们可以对文件的操作有:打开关闭操作open和close;读写操作read和write;改变当前文件位置lseek等。

8.2 简述Unix IO接口及其函数

Unix I/O 接口:

1.打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息,应用程序只需要记住这个描述符。

2.Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO和STDERR_FILENO, 它们可用来代替显式的描述符值。

3.改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行 seek,显式地将改变当前文件位置 k。

4.读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号”。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k。

5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

Unix I/O 函数:

1.打开和关闭文件。

打开文件函数原型:int open(char* filename,int flags,mode_t mode)

返回值:若成功则为新文件描述符,否则返回-1;

flags:O_RDONLY(只读),O_WRONLY(只写),O_RDWR(可读写)

mode:指定新文件的访问权限位。

关闭文件函数原型:int close(fd)

返回值:成功返回0,否则为-1

2.读和写文件

读文件函数原型:ssize_t read(int fd,void *buf,size_t n)

返回值:成功则返回读的字节数,若EOF则为0,出错为-1

描述:从描述符为fd的当前文件位置复制最多n个字节到内存位置buf

写文件函数原型:ssize_t wirte(int fd,const void *buf,size_t n)

返回值:成功则返回写的字节数,出错则为-1

描述:从内存位置 buf 复制至多 n 个字节到描述符为 fd 的当前文件位置

8.3 printf的实现分析

[转]printf 函数实现的深入剖析 - Pianistx - 博客园

vsprintf函数将所有的参数内容格式化之后存入buf,返回格式化数组的长度。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

当程序调用getchar时,程序等待用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车(回车也在缓冲区中)。

当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。

异步异常-键盘中断的处理:当用户按键时触发键盘终端,操作系统将控制转移到键盘中断处理子程序,中断处理程序执行,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区,显示在用户输入的终端内。当中断处理程序执行完毕后,返回到下一条指令运行。

8.5本章小结

       本章介绍了Linux系统下程序的IO管理,包括Linux的IO管理方法和Unix

 Io接口及函数,以printf和getchar为例分析了它们的实现。

结论

在本文中,我们依次跟随hello程序从产生到运行再到最后进程彻底被清理的一生,分析了具体的过程和实现,这个过程经历这些阶段:

  1. 编写原始的hello.c程序,hello.c被以c源程序的形式创建。
  2. 使用gcc -E hello.c -o hello.i命令通过预处理器cpp对hello.c进行预处理得到一个扩充和修改后的hello.i文本文件。
  3. 使用gcc -S hello.i -o hello.s命令通过编译器ccl进行编译,得到汇编语言构成的一个文本文件hello.s。
  4. 使用gcc -c hello.s -o hello.o命令通过汇编器as进行汇编,得到一个二进制的机器代码的可重定位目标文件hello.o。
  5. 由链接器ld进行动态链接,将可重定位目标文件和动态链接库的函数进行链接得到可执行文件hello。
  6. 我们在shell中输入指令运行hello。
  7. Shell为hello程序fork一个进程,并调用execve进行加载,启动了加载器,进行虚拟内存映射,之后hello就成为了系统中的一个进程。
  8. 一些异常可能会对hello进程的执行状况产生影响。
  9. 进程终止时,shell会回收僵死的子进程,内核删除为这个进程创建的所有数据结构。

至此,hello彻底从内核中消失,hello走完了它的一生。

而我在分析hello的一生的过程中,巩固和回忆了本学期计算机系统课程讲授的知识。我们的课程依托深入理解计算机系统这本计算机经典教材,通过对这门课程的学习,我们对现代计算机系统的整体框架和底层原理有了大致的认识和深刻的理解,从计算机内信息的存储,再到处理器体系结构,基本的汇编语言,存储器结构,链接,异常控制,虚拟内存等等。这可以说是我们第一次接触和学习计算机的底层实现,有利于构建关于计算机体系的全面认识网络。

附件

列出所有的中间产物的文件名,并予以说明起作用。

Hello.i 经过预处理后的文本文件

Hello.s 经过编译后的汇编语言源程序文件

Hello.o 二进制的机器代码的可重定位目标文件hello.o

Hello 经过链接后的可执行文件hello

参考文献

[1] Randal E. Bryant, David R. O'Hallaon. 深入理解计算机系统. 第三版. 北京市:机械工业出版社[M]. 2018-1-737

[2] gcc编译的四个步骤:https://blog.csdn.net/xiaohouye/article/details/52084770

[3] printf函数的实现剖析:https://www.cnblogs.com/pianist/p/3315801.html

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值