计算机系统
大作业
目录
计算机科学与技术学院
2021年6月
本文介绍了一个简单的C程序——hello.c在Linux系统中的生命周期。hello.c经过编译系统的预处理、编译、汇编、链接,生成可执行目标文件hello。hello.c在P2P的过程中,需要进程管理、存储管理、I/O管理。本文从hello.c程序执行的角度观察分析计算机系统是怎样软硬件配合、调动各种资源来执行程序的。
关键词:hello;P2P;预处理;编译;汇编;链接;进程;存储;I/O
第1章 概述
1.1 Hello简介
1. P2P
即program to process,从程序到进程。hello.c源程序经过预处理、编译、汇编、链接几个阶段,得到可执行目标文件hello。在shell中输入命令行./hello时,shell检测到这不是内置指令,于是fork一个子进程,并在子进程中execve加载hello并执行,hello从程序成为了运行中的进程。
2. 020
未运行hello时,hello未被装载到内存。shell准备运行hello时,先fork一个子进程,然后在子进程中execve,execve完成删除原有的用户结构、映射新进程的内存区域等工作。开始执行时,程序载入物理内存,流水线CPU通过取值、译码、执行、访存、写回阶段执行机器指令,OS内核通过定时器中断给系统中的各个进程分配时间片,hello和系统中的一些其他进程是并发执行的。取指令、数据时的地址翻译是从VA到PA,使用了TLB、4级页表,取指令、数据使用了3级cache,为这些操作加速。如果执行过程中有外部I/O设备中断,内核会发送信号,就会调用对应的信号处理程序进行响应。hello进程结束后,其仍然占用一定的系统资源,如退出状态等。shell作为父进程负责回收hello,接收SIGCHLD信号并响应,内核删除hello子进程残留的数据结构,内存中就没有和hello有关的内容了。
1.2 环境与工具
硬件环境:x64 CPU;1.8GHz;8.0G RAM
软件环境:Win10;VMWare15;Ubuntu 20.04 LTS
开发调试工具:gcc,edb
1.3 中间结果
文件作用 | |
hello.i | hello.c预处理后得到的修改过的源文件 |
hello.s | hello.i编译后得到的汇编文件 |
hello.o | hello.s汇编后得到的可重定位目标文件(机器指令) |
hello | hello.o链接后得到的可执行目标文件 |
hello1.txt | hello.o反汇编结果文件 |
hello2.txt | hello反汇编结果文件 |
1.4 本章小结
本章简要介绍了hello的P2P,020过程,概括了hello的执行过程,介绍了本次实验的环境和工具、生成的中间结果。
第2章 预处理
2.1 预处理的概念与作用
1. 预处理的概念
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。其主要处理内容有宏定义、文件包含和条件编译。预处理的结果是得到另一个C程序,通常以.i作为拓展名。
2. 预处理的作用
1)处理条件编译
条件编译的功能是根据条件有选择性的保留或者放弃源文件中的内容。常见的条件包含#if、#ifdef、#ifndef指令开始,以#endif结束。用#undef指令可对用#define定义的标识符取消定义。
2)处理文件包含
文件包含指令的功能是搜索指定的文件,并将它的内容包含进来,放在当前所在的位置。文件包含有两种,包含系统文件以及用户自定义文件。
3)处理宏定义
宏的作用是把一个标识符指定为其他一些成为替换列表的预处理记号,当这个标识符出现在后面的文本中时,将用对应的预处理记号把它替换掉,宏的本质是替换。
4)处理行控制、抛错、杂注、空指令等
5)删除注释、添加行号和文件标识符等
2.2在Ubuntu下预处理的命令
预处理指令为:gcc -E hello.c -o hello.i
对hello.c进行预处理,得到结果文件hello.i。
图2.1 ubuntu下预处理指令
图2.2 预处理结果文件
2.3 Hello的预处理结果解析
hello.c文件经过预处理得到了hello.i文件,该文件仍然是一个可读的C程序。观察发现对#include的库进行了包含,包含了许多typedef语句、结构体、共用体等,注释被删除掉了,代码行数也从原本的28行变化成了3065行,且最后部分为原本的代码内容。
图2.3 hello.i文件内容
2.4 本章小结
本章的主要内容为预处理,首先介绍了预处理的概念和主要内容,然后对hello.c文件进行了预处理,并对所得到的hello.i文件进行了分析。
第3章 编译
3.1 编译的概念与作用
1. 编译的概念
编译阶段编译器(cc1)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
2. 编译的作用
1)扫描(词法分析)
扫描器将源代码的字符序列分割成一系列记号。
2)语法分析
基于词法分析得到的一系列记号,生成语法树。
3)语义分析
语义分析器判断是否合法,但不判断对错。分为静态语义和动态语义。
4)源代码优化(中间语言生成)
中间代码使得编译器分为前端和后端,前端产生与机器无关的中间代码,后端将中间代码转换为目标机器代码。
5)代码生成,目标代码优化
代码生成器依赖目标机器的信息生成代码,目标代码优化器通过选择合适的寻址方式、移位代替乘除、删除多余指令等方法优化目标代码。
3.2 在Ubuntu下编译的命令
编译命令为:gcc -S hello.i -o hello.s
对hello.i进行编译,得到结果文件hello.s。hello.s是一个汇编代码文件。
图3.1 ubuntu下编译指令
图3.2 编译结果文件
3.3 Hello的编译结果解析
3.3.1 数据
1. 全局变量
sleepsecs在main函数外定义,是一个初始化了的全局变量,数据类型为int,赋值时发生了类型转换,值为2。它存储在数据段.data中。
图3.3 sleepsecs声明
图3.4 sleepsecs相关信息
2. 字符串常量
有两个字符串常量,它们存储在只读数据段.rodata中。
图3.5 字符串常量1
图3.6 字符串常量2
图3.7 字符串常量相关信息
它们作为参数用于调用printf函数(底层实现为puts)。
图3.8 字符串常量1作参数调用printf
图3.9 字符串常量2作参数调用printf
3. 局部变量
main函数内定义了一个局部变量i,用于控制循环。
图3.10 i的声明
图3.11 i用于控制循环
其对应的汇编代码如下。可见,局部变量i存储在栈中(%rbp-4)处。
图3.12 循环控制汇编代码
4. 常量
有些常量直接作为立即数出现在汇编代码中。
图3.13 汇编中的直接数
3.3.2 赋值
1. 全局变量赋值
图3.14 全局变量sleepsecs声明
初始化的全局变量存储在.data段,由编译器实现,在main中直接就可以使用这个全局值。
2. 局部变量赋值
图3.15 局部变量i声明
汇编指令中使用movl,对栈中的局部变量i进行初始化为0。mov指令加不同的后缀,决定初始化的字节数。b是1字节,w是2字节,l是4字节,q是8字节。
图3.16 初始化i的汇编指令
3.3.3 类型转换
- 隐式类型转换
C语言在以下四种情况会进行隐式转换:1.算数运算式中,低类型能转换成高类型;2.赋值表达式中,右边表达式的值自动隐式转换为左边变量的类型并进行赋值;3.函数调用中传参时,系统隐式地将实参类型转换为形参类型后赋给形参;4.函数有返回值时,系统隐式地将返回值表达式类型转换为返回值类型,赋值给调用函数。
代码中涉及到的属于第2种情况。定义的是int类型,但却赋值2.5,于是2.5隐式转换为int,取整数部分,变成2。
图3.17 sleepsecs声明
图3.18 sleepsecs的值
3.3.4 算数操作
代码中涉及到的算数操作是++,i控制循环,每循环一次i++。
图3.19 循环控制变量i
汇编代码使用addl指令,对i进行加1操作。
图3.20 i++的汇编指令
3.3.5 关系操作
1. !=
图3.21 argc判断语句
汇编代码使用比较语句cmpl实现逻辑比较值是否相等。同时根据比较结果决定是否跳转分支。
图3.22 判断不等的汇编代码
2. <
i<10,用于控制循环。
图3.23 i判断语句
汇编代码中,通过cmpl和jle语句的组合实现<的控制。当i<=9时,条件跳转,继续循环。
图3.24 判断小于的汇编语句
3.3.6 数组操作&指针操作
代码涉及到的数组为命令行参数指针数组char* argv[]。它的每一个元素都是一个指向字符串的指针。它是main函数的一个参数。
图3.25 main函数声明
传参依次用%rdi,%rsi,如下图汇编代码,可知argv[]数组的地址存储在栈中(%rbp-32)处。
图3.26 main函数参数传递
使用argv[]数组的元素时,其汇编代码如下。取出数组首地址,使用add指令对地址加8,然后取出地址对应的元素。argv[]是指针数组,一个元素8字节,因此下列代码展示了取出argv[1]的过程。
图3.27 取出argv[1]的汇编代码
3.3.7 控制转移
1. if条件分支
代码中有if(argc!=3),用于在命令行参数个数不正确时输出提示信息。
图3.28 if条件分支
汇编代码层面,使用cmpl语句和条件跳转语句je的组合来实现if的控制,如果argc=3,则cmpl会设置标志位ZF为1,此时条件跳转je检查条件码,符合要求,则进行跳转,不进入if;否则就不跳转,进入if内部。
图3.29 if条件分支的汇编代码
2. for循环
代码中的for循环使用i作为计数变量进行控制。
图3.30 for循环语句
汇编代码中,使用addl语句对i执行++操作,使用cmpl和jle组合控制跳转,进行循环。cmpl其实实现上和sub语句相似,只是它只改变条件码,不存储减法的结果。jle的跳转条件是ZF=1或SF!=OF。
i初始化为0,当i<9时,cmpl设置SF为1,OF为0,jle进行条件码检查,发现符合,跳转到循环体,执行循环;当i=9,cmpl设置ZF为0,jle进行条件码检查,也符合,跳转,执行循环;而当i=10,cmpl设置ZF=0,SF=0,OF=0。jle进行条件码检查,不符合,就不跳转,跳出了循环,继续向下执行。.L4即为循环体的起始位置。
图3.31 for循环:初始化循环变量
图3.32 for循环:循环变量累加、判断边界条件
3.3.8 函数操作
1. 参数传递
参数个数<=6,依次使用寄存器%rdi,%rsi,%rdx,%rcx,%r8,%r9。6个以外的参数用栈传递。
main函数,它有两个参数,argc和argv。参数传递首先使用寄存器,%rdi,%rsi就用于传递argc和argv。
图3.33 main函数传参
printf函数,有3个参数,用%rdi,%rsi,%rdx传递,传递的是字符串的地址。
图3.34 printf函数传参
sleep函数有一个参数,用%rdi传递,传递一个值。
图3.35 sleep函数传参
exit函数也是有一个参数,%rdi传一个值。
图3.36 exit函数传参
getchar函数不需要参数。
2. 函数调用
调用函数的汇编指令是call,调用函数时,当前函数准备好参数以传递给被调用函数,然后将控制转移到被调用函数,即让PC指向被调用函数的第一条指令。
3. 函数返回
函数返回的汇编指令是leave指令或ret指令,返回时,从栈中读出返回地址并赋给PC。函数返回值存在寄存器%rax中,例如,main函数返回0。
图3.37 main函数返回
3.4 本章小结
本章的主要内容为编译,首先介绍了编译的概念和主要功能,然后对hello.i文件进行了编译,并对所得到的汇编代码文件hello.s中涉及到的各种数据和操作进行了分析,深入探究了编译器是怎样处理C语言的各种数据和操作的。
第4章 汇编
4.1 汇编的概念与作用
1. 汇编的概念
汇编器(as)将helllo.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。
2. 汇编的作用
将汇编代码翻译成机器语言指令,并且生成一个二进制文件。机器指令是计算机能够直接处理的。
4.2 在Ubuntu下汇编的命令
汇编的指令:gcc -c hello.s -o hello.o
对hello.s进行汇编,得到结果文件hello.o。hello.o是一个可重定位目标文件。
图4.1 ubuntu下汇编指令
图4.2 汇编结果文件
4.3 可重定位目标elf格式
ELF文件的一般格式如下图。
图4.3 ELF文件格式
1. ELF头信息
使用指令readelf -h hello.o列出ELF头的信息。
ELF头包含字大小、字节序、文件类型(.o,exec,.so)、机器类型、
节头表位置等信息。
从下图可看出节头表有14个。
图4.4 ELF头
2. ELF节头表信息
使用指令readelf -S hello.o列出ELF节头表的信息。
节头表信息包含节的名称、大小、类型、地址、偏移量、对齐要求等。此时的文件还是可重定位目标文件,因此各个节的地址都还是0,地址还没有确定,链接时会确定它们的地址。
从下图可以看出,有14个节头表,起始位置是偏移量0x4d0处。
图4.5 节头表
3. 重定位信息
使用指令readelf -r hello.o列出重定位信息。重定位信息是链接时使用的。
.rela.text节是.text节的重定位信息,包含链接时在可执行文件中需要修改的指令地址、需修改的指令等。
重定位节的信息有:
- 偏移Offset:需要重定位的内容在文件中的偏移。
- 信息Info
- 重定位类型Type:PC32是PC相对寻址,PLT32是过程链接表寻址,即动态链接。
- 符号值Sym.value
- 符号名称Sym.name:需要重定位的符号的名称。
- 修正值Addend:链接时需要用到的修正地址的值。
图4.6 重定位条目
图4.7 PC相对寻址的重定位算法
图4.8 绝对寻址的重定位算法
4.4 Hello.o的结果解析
objdump -d -r hello.o分析hello.o的反汇编。
图4.9 hello.o反汇编结果的代码段
与第3章的 hello.s进行对照分析。
图4.10a
图4.10b
图4.10 hello.s文件
经过对照,发现汇编代码内容基本相同,不过objdump得出的反汇编中还有汇编代码对应的机器码。
机器语言是二进制语言,只由0、1组成,是计算机可以直接处理的字节码。C语句最后都会转换成机器码,才能由处理器执行。每一条汇编指令都有与其对应的机器指令,它们之间存在一一映射。机器指令由操作码和操作数组成,其操作码对应汇编指令的操作部分(mov,add等),其操作数对应汇编指令涉及的寄存器、立即数等。
1. 操作数不一致:机器语言中,操作数(包括寄存器等)都使用字节码表示,并且往往是小端序存储的。比如下图所示的mov指令的机器代码。
图4.11 mov指令和机器码
2. 分支转移:机器语言中分支跳转是向一个给定地址跳转,而.s文件分支跳转就是向一个符号标记的代码段跳转。因为汇编成机器语言后,各个语句就有了相应的地址,可以按照地址跳转。
图4.12 反汇编文件的jmp语句
图4.13 汇编文件的jmp语句
3. 函数调用:机器语言中的函数调用是call的指令码+相对地址。在hello.o反汇编的结果中,函数调用均为call的指令码+4字节全0,因为hello.c调用的exit,getchar,printf等都是共享库函数,在汇编时无法确定相对地址,因此就设置为0,交给链接阶段来确定这个值。.s文件中函数调用是call+函数名,汇编代码其实就是一种便于人阅读的助记符。
图4.14 反汇编文件的函数调用
图4.15 汇编文件的函数调用
4.5 本章小结
本章的主要内容为汇编,首先介绍了汇编的概念和主要功能,然后对hello.s文件进行了汇编,并分析了得到的可重定位目标文件hello.o的ELF格式的相关内容,还将hello.o进行了反汇编,比较了机器语言和汇编语言的区别。
第5章 链接
5.1 链接的概念与作用
1. 链接的概念
链接是链接器将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可以被加载到内存并执行。链接包含静态链接、动态链接等,主要步骤是符号解析和重定位。
2. 链接的作用
通过符号解析和重定位,生成一个可执行目标文件,这个文件可以被加载到内存并执行。
5.2 在Ubuntu下链接的命令
ld的链接命令: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 hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o -o hello
链接后得到可执行文件hello。
图5.1 ld链接命令
图5.2 链接结果文件
5.3 可执行目标文件hello的格式
1. ELF头
指令:readelf -h hello
可以看出文件类型为可执行文件EXEC,有27个节头表。
图5.3 ELF头
2. 节头表
相比.o文件,可执行文件的节数多了一些。
图5.4a
图5.4b
图5.4 节头表
3. 程序头部表:描述段的信息
程序头部表描述了各个段的信息,由下图,hello有12个段。每个条目的信息有段的类型、偏移、大小、虚拟地址、占用内存大小、物理地址、标志和对齐要求等。
图5.5 程序头部表
程序头部表还包含了节到段的映射信息,其中段用段号表示。
图5.6 节到段的映射信息
5.4 hello的虚拟地址空间
使用edb加载hello,选择view-memory regions,查看本进程的虚拟地址空间各段信息。
图5.7 hello的内存区域
在data dump可以看到更详细的信息。根据段头表中的虚拟内存地址和段大小,可以查看到对应段的信息。如下图地址0x4002e0处就对应5.3中那个类型为INTERP的段。
图5.8 hello的data dump中查看INTERP段
图5.9 hello的INTERP段
5.5 链接的重定位过程分析
objdump -d -r hello,得到可执行文件hello的反汇编文件。
链接有两个步骤:1.符号解析:链接器将每一个符号引用与一个确定的符号定义关联起来;2.重定位:将多个单独的代码节和数据节合并成单个节,将符号从它们在.o文件中的相对位置重新定位到可执行文件中的绝对内存位置,并用这些新位置更新对符号的引用。
对比hello和hello.o的反汇编文件,发现了一些不同,这些不同体现出了重定位的过程:
1. 将代码节、数据节合并
hello的反汇编中,出现了合并自共享库的代码、启动main函数的代码等,而hello.o的反汇编中.text段只有main函数。
图5.10 hello的反汇编中来自共享库的内容
2. 将符号由相对位置重定位到绝对位置
hello.o的反汇编中,main函数等内容的地址是0,是等待链接时再确定地址;指令也只有比较小的相对地址值。而hello的反汇编中,main函数等内容已经有了确定的虚拟地址,下图中main函数的虚拟地址是0x401105;指令也有了确定的虚拟地址。
图5.11 hello.o的反汇编中main地址为0
图5.12 hello反汇编中main地址为0x401105
3. 用新位置更新对符号的引用
例如,hello.o中调用exit函数,其相对地址为全0,是先占位、等待链接时确定;而hello中调用exit函数,由于exit函数已经有了确定的地址,因此也可以计算出相对地址,并用这个计算出的4字节相对地址替换之前占位的0。
图5.13 hello.o反汇编,指令用0给相对地址占位
图5.14 hello反汇编,指令有计算出的相对地址
hello中对符号进行重定位时,需要用到重定位条目中的信息。例如.text使用的全局变量sleepsecs,其offset=0x64,addend=-4,addr(s)=addr(.text_main)=0x401105
由以下公式,refaddr=addr(s)+offset=0x401169
*refptr=0x404044-4-0x401169=0x2ed7,与汇编代码相符合。
图5.15 重定位条目
图5.16 PC相对寻址重定位算法
图5.17 移动sleepsecs的mov指令和机器码
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
子程序名 | 地址 |
ld-2.31.so | 0x7f2a0e2d5100 |
hello!_start | 0x4010d0 |
libc-2.31.so!_libc_start_main | 0x7ff64172dfc0 |
libc-2.31.so!__cxa_atexit | 0x7ff641750f60 |
hello!_libc_csu_init | 0x401190 |
hello!_init | 0x401000 |
libc-2.31.so!__setjmp | 0x7ff64174ce00 |
libc-2.31.so!__sigsetjmp | 0x7ff64174cd30 |
hello!puts@plt | 0x401030 |
hello!exit@plt | 0x401060 |
hello!printf@plt | 0x401040 |
hello!sleep@plt | 0x401070 |
hello!getchar@plt | 0x401050 |
libc-2.31.so!_exit | 0x7ff6417ed290 |
5.7 Hello的动态链接分析
在重定位条目中,重定位类型为PLT32的都是需要通过过程链接表进行动态链接的。
动态链接是在程序加载时进行链接的方法,这种链接方法更灵活。在静态链接阶段,程序中使用的动态链接库中的函数,只依赖动态库进行符号解析,重定位装载时进行。
调用共享库函数时就进行动态链接,利用全局偏移表GOT和过程链接表PLT实现。PLT每个条目是16字节。GOT每个条目是8字节。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.so模块中的入口点。
下图为dl_init前的GOT表项,初始时GOT[1],GOT[2]均为8字节全0。
图5.18 dl_init前的GOT表项
dl_init后,GOT[1]和GOT[2]有了确切的值。
图5.19 dl_init后的GOT表项
5.8 本章小结
本章的主要内容为链接,首先介绍了链接的概念和主要功能,然后对hello.o文件进行了链接,并分析了得到的可执行目标文件hello的ELF格式的相关内容、其虚拟地址空间,还将hello进行了反汇编,分析了链接的重定位过程。还通过edb调试分析了hello的执行流程和动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
1. 进程
一个正在运行的程序的实例。
2. 进程的作用
它提供给应用程序两个关键抽象:第一,每个程序似乎独占地使用CPU,这由OS内核通过上下文切换机制实现;第二,每个程序似乎独占地使用内存系统,这由OS内核的虚拟内存机制实现。
6.2 简述壳Shell-bash的作用与处理流程
1. shell的作用
shell是一个交互型、应用级程序,它代表用户运行其他程序
2. shell的处理流程
1)读
从命令行读入用户指令。
2)求值
解析用户指令,代表用户执行。如果是内置指令,立即执行;否则创建子进程并在子进程中运行新程序。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的、处于运行状态的子进程。子进程返回0,父进程返回子进程的PID。新创建的子进程几乎与父进程相同:子进程与父进程有相同的虚拟地址空间(但是是独立的一份副本),子进程获得与父进程任何打开文件描述符相同的副本。但子进程有不同于父进程的PID。
当在shell中输入命令行./hello时,shell经过解析,发现这不是内置指令,就会fork一个子进程,以准备运行可执行文件hello。
6.4 Hello的execve过程
输入./hello后,shell会fork一个子进程,并通过execve在子进程中加载、运行hello。execve会覆盖当前进程的代码、数据、栈,但会保留当前进程的PID、已打开的文件描述符和信号上下文。
execve函数在当前进程中加载并运行新程序hello的步骤为:
由于fork得到的子进程的代码、数据等都和父进程一样,而执行新程序需要其自己的代码等,因此需要将原本的用户区域删除。
2. 创建新的区域结构
将目标文件hello提供的代码和初始化数据映射到内存中的.text和.data区。将.bss和栈映射到请求二进制0的匿名文件。
通过前两步,实现了覆盖当前进程的代码、数据、栈。
3. 设置PC,指向代码区域入口点
6.5 Hello的进程执行
系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需要的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
逻辑流是一个进程运行时PC值的序列。进程是轮流使用处理器的,每个进程执行它的流的一部分,然后被暂时挂起,然后轮到其他进程。一个进程执行它的控制流的一部分的每一时间段叫做时间片。
处理器通常用某个控制寄存器中的一个模式位来描述进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中,一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位、或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区的代码和数据。任何这样的尝试都会导致致命的保护故障。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用上下文切换机制将控制转移到新的进程。上下文切换:保存当前进程的上下文,恢复某个先前被抢占的进程的上下文,将控制传递给这个新恢复的进程。
hello在刚开始运行时,处于用户模式。当运行到sleep函数时,显式请求让调用进程hello休眠。这时进入内核模式,定时器开始计时,内核进行上下文切换将控制权交给其他进程,进入其他进程的用户模式。当定时器到时时(2s)发送一个中断信号,此时进入内核模式执行中断处理,恢复hello的上下文,hello进程就可以继续执行它的逻辑流。
图6.1 调用sleep函数后的进程切换
hello调用getchar函数时,实际使用了read系统调用。内核采用和调用sleep函数相似的上下文切换机制进行调度。这里就不再详细叙述了。
6.6 hello的异常与信号处理
1. hello正常执行的结果
如下图,最后一行是getchar函数的输入。
图6.2 hello正常执行的结果
2. hello可能出现的异常
异常 | 原因 | 处理(内核) |
中断:异步 | 来自I/O设备的信号,如键盘输入 | 当前指令处理完后,中断处理程序进行处理,然后返回到下一条指令 |
陷阱:同步 | 有意的异常,执行指令的结果,hello执行sleep函数会出现 | 陷阱处理程序处理,然后将控制返回到下一条指令 |
故障:同步 | 可能被恢复的,执行hello可能出现缺页故障 | 故障处理程序处理,要么重新执行引起故障的指令(已恢复),要么终止 |
终止:同步 | 不可恢复的致命错误造成 | 终止当前程序 |
3. 不停乱按(非组合键)
只按字母键等,会在屏幕显示,但不影响程序正常输出。如果输入最后是回车,getchar会读走回车,把回车前的字符串当成shell指令解析。
图6.3 乱按键盘时的结果
4. Ctrl-Z及一些命令
Ctrl-Z会发送SIGTSTP信号给shell,使当前前台进程挂起,如下图。
图6.4 按Ctrl-Z的结果
Ctrl-z后可以运行ps jobs pstree fg kill 等命令。
ps:列出当前(执行ps指令时)正在运行的进程的快照,包括其PID等信息。
jobs:列出当前在后台运行的作业、已停止的作业和状态已经更改但未向shell报告的作业。
图6.5 ps及jobs指令
pstree:以树状图显示进程间的关系。
图6.6 pstree指令
fg:将后台进程调至前台继续运行。如下图,挂起时hello只输出了2次,当使用fg指令将其调至前台继续运行时,它会继续输出剩下的8次。
图6.7 fg指令
kill:发送一个信号给进程。指令格式为kill -s pid,-s为信号序号,pid为接受该信号的进程的PID。如下图kill -9,就是杀死进程。杀死进程后再ps,发现当前运行的进程中没有hello了。
图6.8 kill指令
5. Ctrl-C
Ctrl-C发送SIGINT信号给shell,将当前前台进程终止。ps可以看出当前运行的进程中没有hello了。
图6.9 按Ctrl-C的结果
6.7本章小结
本章从进程的角度看hello的执行。首先简要介绍了进程和shell,然后分析了执行hello时fork的过程、execve的过程,以及hello的进程执行和进程执行时可能遇到的异常和处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:在有地址变换功能的计算机中,访存指令给出的地址(操作数)叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。
线性地址:地址空间是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间。线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:Windows程序运行在386保护模式下时,程序访问存储器所使用的逻辑地址称为虚拟地址,与实地址模式下的分段地址类似,虚拟地址也可以写为“段:偏移量”的形式,这里的段是指段选择器。
物理地址:放在寻址总线上的地址。放在寻址总线上,如果是读,电路根据这个地址每位的值就将相应地址的物理内存中的数据放到数据总线中传输。如果是写,电路根据这个地址每位的值就在相应地址的物理内存中放入数据总线上的内容。
在hello的汇编代码中出现的都是虚拟地址,在运行时这些地址要被翻译成物理地址,才能读写数据。
7.2 Intel逻辑地址到线性地址的变换-段式管理
1. 实地址模式(IA32)
在实地址模式下,处理器使用20位地址总线,可以访问1M内存。但8086模式只有16位地址线,不能直接表示20位地址,采用内存分段的解决方法,将内存分为64KB的段,段地址(低6位一定为0,可以只存高16位)放在16位段寄存器中,用段地址+偏移地址的方法表示地址。
图7.1 段:偏移地址示意
例:20位线性地址的计算
1)08F1:0100
线性地址:08F1H*10H+0100H = 09010H
2)8000:0250
线性地址:8000H*10H+0250H = 80250H
2. 保护模式
寄存器、地址总线都是32位,32位地址总线寻址,每个程序可寻4GB内存。段寄存器指向段描述符,一个段描述符8字节,包括段的参数、保护属性等。段描述符集中存放,就是段描述符表。段寄存器保存的是段描述符在段描述符表中的索引值,称为段选择器,而非段地址。
全局描述符表GDT:包含操作系统使用的代码段、数据段、堆栈段的描述符,以及各任务/程序的LDT段。
局部描述符表LDT:每个任务有一个独立的LDT,包含对应任务/程序私有的代码段、数据段、堆栈段的描述符和对应任务/程序使用的门描述符等。
48位全局描述符表寄存器GDTR:指向GDT在内存中的具体位置。
16位局部描述符表寄存器LDTR:指向LDT在GDT中的位置(索引)。
16位段选择器/段选择子编码:高13位是索引,0、1位表示程序当前优先级RPL,第二位是TI位,表示段描述符的位置,TI=0,段描述符在GDT中,TI=1,段描述符在LDT中。
保护模式下的段寻址如下图所示。
段寄存器为全局描述符表项的寻址:1-2-3
段寄存器为局部描述符表项的寻址:1’- 2’- 3’-4’-5’
图7.2 保护模式下的段寻址
7.3 Hello的线性地址到物理地址的变换-页式管理
将内存分割成4KB大小的页(Pages),同时将程序段的地址空间按内存页的大小进行划分。
分页模式的基本思想:当任务运行时,当前活跃的执行代码保留在内存中,而程序中当前未使用的部分,将继续保存在磁盘上。当CPU需要执行的当前代码存储在磁盘上时,产生一个缺页错误,引起所需页面的换进(从磁盘载入内存)。
页表是一个页表条目的数组,将虚拟页地址映射到物理页地址。每个进程都有自己的页表。
图7.3 页表示意
基于页表的地址翻译过程:用页表基址寄存器中存储的当前进程的页表地址找到页表,用虚拟页号作为索引去查询页表,如果页表条目有效,则取出物理页号,和虚拟页偏移量拼接得到物理地址;如果页表条目无效,则缺页。
图7.4 基于页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是MMU中一个小的具有高相连度的集合,它实现了虚拟页号向物理页号的映射,页数很少的页表可以完全放在TLB中。MMU使用虚拟地址的VPN部分访问TLB。其访问方式和cache类似,先用组索引确定组,然后用标记进行组内匹配,但是TLB没有块偏移。
图7.5 TLB示意
多级页表的优点是节省内存,若一级页表项为空,对应的二级页表等就不存在,且只有一级页表常驻主存中。以二级页表为例,一级页表有1页,每个表项指向一个二级页表;二级页表有多页,每个表项指向1个虚拟页/物理页,二级页表是可以在内存中调入调出的。
基于k级页表的地址翻译:将虚拟地址的VPN部分分为多段,类似于基于页表的直接翻译,每一段VPNi作为一个虚拟页号去查询i级页表,得到下一级页表的基址,直到最后查询到物理页号,就可以得出物理地址。
图7.6 基于k级页表的翻译
Core i7的四级页表翻译。虚拟地址共48位,36位为VPN,分为VPN1,VPN2,VPN3,VPN4,各为9位,可寻址512KB的空间。12位为VPO。首先用CR3寄存器的值找到L1页表的物理地址(40位PPN),然后用VPN1作为索引,查找对应L1页表中的表项(对应L2页表的物理地址,40位PPN),然后用VPN2作为索引,查找对应L2页表中对应的表项,以此类推,直到从L4页表中得到了页的物理地址(40位PPN),和VPO拼接即得到了完整的页的物理地址。
图7.7 core i7四级页表翻译
7.5 三级Cache支持下的物理内存访问
Core i7的三级cache结构如下图。其中L1缓存分为数据缓存和指令缓存,L2缓存各个核独立,L3缓存所有核共享。
图7.8 core i7三级cache示意
下面分析一下L1 cache的机制,L2、L3机制类似,不再详细介绍。
缓存分组,一组中可以有多行,一行为一个缓存块。物理地址分为组索引、块偏移和标记,其中组索引用于在cache中选择组,标记用于在组中匹配行,块偏移用于在行中选择数据。
需要对一个数据进行读写时,首先到L1 cache中去找,如果找到(标记匹配且行有效),则按照块偏移找到数据,对其进行读写;如果没有找到,就到L2去找,再没有找到就到L3、主存去找,找到之后读入L1 cache,此时如果L1有空行,就选择空行读入,否则,就使用LRU策略等,驱逐旧行、读入新行。
图7.9 cache结构示意
图7.10 访问cache时的地址分割
7.6 hello进程fork时的内存映射
fork函数为新进程创建虚拟内存,提供私有的虚拟地址空间。fork函数创建当前进程的mm_struct,vm_area_struct和页表的原样副本,并把两个进程中的每个页面都标记为只读,每个区域结构(vm_area_struct)都标记为私有的写时复制(COW),因此,在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存。随后的写操作通过写时复制机制创建新页面。
7.7 hello进程execve时的内存映射
execve在当前进程加载并运行新程序时的内存映射:
将目标文件hello提供的代码和初始化数据映射到内存中的.text和.data区。将.bss和栈映射到请求二进制0的匿名文件。将共享库的代码和数据等映射到共享库的内存映射区域。
图7.11 execve时的内存映射
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。如下图,CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。
图7.12 发生缺页
接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在VP3已经在主存中了,页命中,地址翻译硬件可以正常处理。
图7.13 缺页处理结果
7.9动态存储分配管理
动态内存分配器维护着进程的一个虚拟内存区域,称为堆。分配器将堆视为一组不同大小块的集合,每个块要么是已分配的,要么是空闲的。
分配器的类型有显式分配器和隐式分配器,显式分配器要求应用显式地释放任何已分配的块,例如C语言中的malloc和free;隐式分配器是应用检测到已分配块不再被程序所使用时就释放该块,例如java等语言的垃圾收集机制。
记录空闲块的方法有多种,比如:隐式空闲链表、显式空闲链表、分离的空闲列表、块按大小排序等。
1. 隐式空闲链表
隐式空闲链表的每个块都需要记录块的大小和分配状态。a=1已分配,a=0未分配,其具体格式如下图,每个块由块头部、有效荷载、可选的填充组成。空闲块通过块中的大小信息隐式链接。
图7.14 隐式空闲链表的块结构
图7.15 隐式空闲链表
找到一个空闲块的策略有首次适配、下一次适配、最佳适配等。首次适配从头开始搜索空闲链表,选择第一个合适的空闲块,其搜索时间与总块数是线性关系,倾向于在靠近链表起始处留下小空闲块的碎片。下一次适配和首次适配类似,只是从链表中上一次查询结束的地方开始,它避免重复扫描无用块,因此比首次适配更快,且内存利用率也比首次适配高。最佳适配是查询链表,选择一个适配且剩余空间最少的空闲块,其内存利用率高,但速度通常慢于首次适配。
分配空闲块即对空闲块进行分割,分配出符合要求的大小。
释放一个块的简单实现就是清除已分配标志,但是可能会产生假碎片,因此需要对空闲块进行合并。
合并空闲块的策略有:直接和下一个空闲块合并、双向合并等。双向合并需要块中再添加一个脚部,以实现反查链表。
2. 显式空闲链表
只维护空闲块的链表,而不是所有块。已分配块只需要存储边界标记,空闲块中需要存储前/后指针和边界标记。
图7.16 显式空闲链表的块结构
与隐式空闲链表相比较,其分配时间从块总数的线性时间减少到空闲块数量的线性时间,因此当大量内存被占用时其速度快,但是由于需要在链表中拼接块,其释放和分配稍显复杂。
一种方法是使用后进先出(LIFO)的顺序维护链表,将新释放的块在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章主要内容是hello的存储管理,先介绍了地址空间的相关概念,然后介绍了段式管理和页式管理机制,接着介绍了TLB与四级页表支持下的VA到PA的变换、三级Cache支持下的物理内存访问,然后从内存映射的角度分析了hello进程fork时、execve时的内存映射,最后介绍了缺页故障与缺页中断处理、动态内存分配管理等内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有I/O设备,例如网络、磁盘、终端,都被模型化为文件,而所有输入和输出都被当做对相应文件的读/写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单的、低级的应用接口,称为Unix I/O。这使得所有输入输出都能以一种统一且一致的方式执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
1. Unix IO接口
1)打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想访问一个IO设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录关于这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux shell创建的每个进程开始时都有3个打开的文件
标准输入(描述符为0),标准输出(描述符为1),标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可以用来代替显式的描述符值。
2)改变当前文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作显式地设置文件的当前位置k。
3)读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小m字节的文件,当k>=m时执行读操作会触发一个称为EOF的条件,应用程序能够检测到这个条件,在文件结尾处并没有明确的“EOF符号”。
写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
4)关闭文件
当应用程序完成了对文件的访问后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论进程因何种原因终止,内核都会关闭所有打开的文件,并释放它们的内存资源。
2. UNIX IO函数
1)打开文件:int open(char* filename, int flags, mode_t mode)
进程通过调用open函数来打开一个存在的文件或是创建一个新文件。open函数返回小的描述符数字——文件描述符,返回的描述符总是在进程中当前没有打开的最小描述符,fd==-1说明发生错误;flags参数指明了进程打算如何访问这个文件;mode参数指定了新文件的访问权限位。
关闭文件:int close(fd)
fd是需要关闭的文件的描述符,正常关闭返回0,出错返回-1。
2)读文件:ssize_t read(int fd, void *buf, size_t n)
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
3)写文件:ssize_t wirte(int fd, const void *buf, size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。返回值小于0表示出错。
8.3 printf的实现分析
printf函数的源代码:
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;
}
其参数为:格式控制字符串fmt,...表示一个可变形参的写法,当参数个数不确定时就可以这样书写。
printf的底层实现利用了vsprintf函数和系统调用write。
vsprintf函数作用是接受确定输出格式的格式字符串fmt(输入)。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
write函数将buf中的i个元素写到终端。
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是要打印的字符串就显示在了屏幕上。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar函数的源代码为:
int getchar(void)
{
static char buf[BUFSIZ];
static char *bb = buf;
static int n = 0;
if(n == 0)
{
n = read(0, buf, BUFSIZ);
bb = buf;
}
return(--n >= 0)?(unsigned char) *bb++ : EOF;
}
getchar函数底层使用read系统调用实现。
当程序调用getchar时,程序就等待用户按键,用户输入的字符被存放在键盘缓冲区中,直到用户按回车为止(回车字符也放在缓冲区中)。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍IO管理,先介绍了Linux IO的管理方式,然后简单介绍了UNIX IO接口及函数,并分析了IO函数printf、getchar的底层实现。
结论
1.hello的历程
- 预处理:根据#开头的指令,修改原始C程序,得到hello.i文件,hello.i仍然是一个C程序文件。
- 编译:将C程序翻译成汇编程序,得到一个汇编文件hello.s。
- 汇编:将汇编程序翻译成机器指令,机器指令是CPU能够直接处理的二进制指令。得到一个可重定位目标文件hello.o,该文件中各符号、指令的运行时地址都未确定,等待链接时确定。
- 链接:经过符号解析、重定位,确定符号、指令的运行时地址,得到一个可执行目标文件hello。
- 创建进程:在shell中输入命令行./hello,shell进行fork,为hello创建子进程。
- 加载程序:execve函数删除原本的用户区域,映射hello的内存区域,实现hello的加载。
- 异常处理、信号处理:通过异常处理程序和信号处理程序,及时响应系统内的异常和产生的信号,使得系统更健壮。
- 存储管理:物理内存作为虚拟内存的缓存,通过TLB、4级页表等,简化地址管理、加快寻址。cache机制用更小、更快的存储器作为更大、更慢的存储器的缓存,利用程序的局部性,加快取数据、指令。
- 回收进程:hello子进程结束后,父进程负责回收,内核删除其对应的数据结构。
2. 对计算机系统设计与实现的感悟
- 计算机系统中,缓存思想很普遍,是为了协调快速设备与慢速设备的。
- 计算机系统的设计很周全,充分考虑了可能出现的异常等,并创建了异常处理机制。
- 计算机系统的软硬件相互适应、相互配合,为更好地执行程序服务。
附件
文件名称 | 文件作用 |
hello.i | hello.c预处理后得到的修改过的源文件 |
hello.s | hello.i编译后得到的汇编文件 |
hello.o | hello.s汇编后得到的可重定位目标文件(机器指令) |
hello | hello.o链接后得到的可执行目标文件 |
hello1.txt | hello.o反汇编结果文件 |
hello2.txt | hello反汇编结果文件 |
参考文献
[1] Randal E.Bryant,David O'Hallaron. 深入理解计算机系统[M]. 龚奕利,贺莲. 机械工业出版社,2016.