本文主要从hello.c这个小程序入手,对它从预处理、编译、汇编、链接、进程创建回收等一步步生成过程进行了深入的解析和说明。结合CSAPP这本书和Ubuntu虚拟机的相关知识进行了实践操作,把计算机系统整个体系串联在一起,做到了理论知识和动手实践的结合。
关键词:计算机系统;汇编;链接;Ubuntu;csapp
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 7 -
3.2 在Ubuntu下编译的命令............................................................................. - 9 -
4.2 在Ubuntu下汇编的命令........................................................................... - 17 -
5.2 在Ubuntu下链接的命令........................................................................... - 23 -
5.3 可执行目标文件hello的格式.................................................................. - 24 -
5.5 链接的重定位过程分析............................................................................... - 27-
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 31 -
6.3 Hello的fork进程创建过程..................................................................... - 32 -
6.6 hello的异常与信号处理............................................................................ - 33 -
第7章 hello的存储管理............................................................................... - 37 -
7.1 hello的存储器地址空间............................................................................ - 37 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 37 -
7.3 Hello的线性地址到物理地址的变换-页式管理...................................... - 38 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 39 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 40 -
7.6 hello进程fork时的内存映射.................................................................. - 41 -
7.7 hello进程execve时的内存映射.............................................................. - 41 -
7.8 缺页故障与缺页中断处理........................................................................... - 42 -
8.1 Linux的IO设备管理方法.......................................................................... - 44 -
8.2 简述Unix IO接口及其函数....................................................................... - 44 -
第1章 概述
1.1 Hello简介
当我们在编写程序的IDE中写下hello程序的最后一个字符,点击“编译并运行”之后,hello便开启了它传奇而又精彩的一生。
首先是关于.c文件的预处理:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第一行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。这样就得到了另一个C程序通常是以.i作为文件拓展名。总的来说,cpp会将所有的头文件都合成为一个.i文件。
第二步是编译:编译器(ccl)将文本文件hello.i翻译为文本文件hello.s,它包括一个汇编语言程序。该程序包含函数main的定义,其汇编语句我们之后会提到,在这里先略过。
第三步,汇编:汇编器(as)将hello.s翻译为机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在文件hello.o中。hello.o文件是一个二进制文件,在windows下不能直接查看,但是在linux系统下可以通过链接得到可执行文件并执行。
第四步,链接:我们可以注意到,hello程序中调用了一个printf函数,它是每个C编译器提供的标准C库中的一个函数。Printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中,链接器(ld)就负责处理这种合并。结果就得到hello文件,它是一个可执行目标文件(或简称可执行文件),可以被加载到内存中,由系统执行。
第五步,在Shell中运行:用户在Shell中输入指令./hello。在shell读入命令行之后,首先会判断此命令行是否为内部指令,若为内置命令则直接执行,否则shell会调用fork函数创建一个新的子进程并调用execve函数把hello的内容加载到子进程的地址空间。在hello成为进程之后,便会受到信号的控制,例如键盘输入ctrl+z或ctrl+c分别表示挂起(SIGSTP)和终止(SIGINT)的信号。在键盘输入这些信号之后,系统会根据信号默认行为对相应进程进行操作。
最终,回收进程:当Hello执行return语句后,它就将处于一种叫终止的状态(没有运行但仍然占用内存),也叫僵死进程。为了回收这些僵死进程,系统会让shell调用waitpid函数让父进程对子进程进行回收。一旦hello的进程被回收成功,hello的生命周期便结束了,我们的hello传奇的一生也就到此终止。
1.2 环境与工具
1.硬件环境:处理器:AMD Ryzen 7 5800H
内存:16GB
主显卡:RTX 3060
2.软件环境:Windows 10 64位;Ubuntu20.04
3.工具:gcc,as,ld,vim,edb,gdb,readelf,VScode,VS
1.3 中间结果
文件 | 作用 |
hello.c | 源文件 |
hello.i | 预处理后生成的文件 |
hello.s | 编译后生成的文件 |
hello.o | 汇编后生成的可重定位目标程序 |
hello | 最终生成的可执行目标文件 |
hello.asm | hello.o反汇编生成的文件 |
hello.elf | hello.o的elf格式文件 |
hello2.asm | hello反汇编生成的文件 |
hello2.elf | hello的elf格式文件 |
1.4 本章小结
本章对hello程序的产生过程从预处理、汇编到进程处理等一步步过程作了概括性介绍,并列举出了大作业实验的软硬件环境和使用的工具。之后会将hello生成过程的每一步进行更加详细更加专业性的说明。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:
预处理是指程序开始运行时,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序的过程。例如,hello.c文件中的#include 命令会告诉预处理器读取系统头文件stdio.h,unistd.h,stdlib.h 的内容,并把这些内容直接插入到程序文本中。此外,预处理过程还会删除程序中的注释和多余的空白字符。最终通常得到一个以.i作为拓展名的C程序,这个.i文件仍然是一个文本文件。
作用:
- 替换#define定义的字符串
- 处理特殊符号并进行替换
- 根据#if 后面的条件决定需要编译的代码
2.2在Ubuntu下预处理的命令
命令格式:cpp hello.c > hello.i(如下图2-1)
(图2-1)
2.3 Hello的预处理结果解析
在完成之前的预处理操作之后,我们打开生成的hello.i文件可以看到文件内容明显增加,从最初的几行增加到了3060行(展示为下图2-2),在最后几行我们可以找到最初的hello程序。可以发现经过预处理操作之后,.i文件的程序也和最初的.c文件有差别,具体表现为:
- 删除空白和注释
- 替换宏定义,对程序中的宏定义递归展开
- 增加for循环对#if判断语句进行优化
(图2-2)
2.4 本章小结
本章主要介绍了预处理的概念和作用,通过在Ubuntu下输入预处理指令将hello.c文件变成hello.i文件为例,具体详细地说明了linux系统下.c文件经过cpp处理后生成.i文件的过程。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:
编译是指汇编器(ccl)将高级语言程序文本文件(.i文件)翻译成为汇编语言文件(.s文件)的过程,为后续转化为二进制机器码做准备。编译主要分为三个步骤:
- 词法与语法分析:将字符串转化为标记流,之后将此标记流生成语法树。
- 转化目标代码:将语法树转化为目标代码
- 代码优化:将代码进行面向系统的自动优化
作用:
将高级语言转化为汇编语言,为之后转化为二进制机器码做准备
3.2 在Ubuntu下编译的命令
命令格式:gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s(如下图3-1)
(图3-1)
3.3 Hello的编译结果解析
3.3.1从主函数分析数据类型和数据段:
(图3-2)
(图3-3)
从主函数的结构中(图3-2、3-3)我们可以看到一些数据类型,下面对主函数中出现的这些数据类型进行一一说明:
.file:源文件,后接源文件名
.data:数据段,存储已经初始化的全局变量和静态变量
.text:代码段
.rodata:只读代码段
.align:地址对齐格式
.string:字符串段
.global:全局变量
.size:数据空间大小
下面我们将对各种数据类型和各种操作进行详细说明。
3.3.2:各种数据类型
1.整数:
(1)int i:局部变量,在程序运行中被存储在寄存器或者栈空间中,从下图(图3-4)可以看出局部变量i占用了四个字节的地址(图3-4)
(2)int argc :是main函数的参数,保存在堆栈中
(3)3:立即数3,在汇编语言中以$3给出
2.数组:
(1)char *argv[]:是主函数的第二个参数,由汇编代码(图3-5)可以看出此数组的首地址保存在栈中,通过地址偏移访问各个数组内元素。在访存时,使用%rax寄存器间接访存。
(图3-5)
3.字符串:通过查看汇编代码,我们可以发现hello.s中存储了两个字符串,
(如下图3-6),他们分别对应源程序中的第14行和第18行(如图3-7)
(图3-6) (图3-7)
在图3-5中第6行,我们可以看到除了英文Hello和数字学号以外,其他的汉子都以\xxx的形式给出,其表示的是UTF-8编码,一个汉字由三个字节表示。两个string字符串都存放在.rodata只读数据段中
3.3.3:赋值操作
程序中有非常多地方涉及赋值操作,其中绝大部分由mov指令完成,例如下图(图3-8)中对%edi赋值的操作
(图3-8)
值得注意的是,mov指令后缀指的是操作数的位数,不同的后缀字母表示不同位数,例如b—1B, w—2B, l—4B, q—8B。
3.3.4:算术操作
汇编代码中涉及最多的算术操作就是add操作,例如下图(图3-9)中最后一行addl,便是在每次循环结束后对栈顶指针进行+4的操作。其次,汇编语言中还有很多本程序中没有涉及到的算术操作,如图3-10所示。
(图3-9)(图3-10)
3.3.5:关系控制转移操作
对比原程序和生成的.s汇编代码,我们可以发现汇编代码中有两处条件转移操作(图3-11、3-12),它们分别对应源代码中的if判断和for循环的判断(图3-13)
(图3-11) (图3-12) (图3-13)
1.if判断语句的分析:上图中if判断语句的内容为if(argc!=4),即当argc不为4时执行printf和exit函数,否则执行下一行(进行for循环)。其汇编代码对应为图3-10,使用cmpl $4,-20(%rbp),比较 argc与4是否相等,若相等,则跳转至.L2,不执行后续部分内容;若不等则继续执行函数体内部对应的汇编代码。值得注意的是,在汇编代码的控制转移操作中,为了达到控制的目的,cmp操作通常和jmp操作结合。例如本if判断语句的汇编代码:
cmpl $4, -20(%rbp)
je .L2
第一行表示将%rbp栈顶指针-20的地址位置的内容与立即数4进行32位数的对比,第二行je即指若二者相等则执行跳转指令,跳转到L2,否则继续执行下一行指令。Je的含义是相等时跳转,jmp操作有特别多的类型,我们在下图3-14中列出。
(图3-14)
2.for循环操作结束条件的判断:源程序中for循环语句为:for(i=0;i<9;i++),当i < 10时进行循环,每次循环i++。在hello.s中的汇编语句体现在图3-15中的cmpl与jle命令中。利用cmpl $8, -4(%rbp)比较i是否与8相等,当i<=8时则继续循环,进入.L4,i>8时跳出循环。
(图3-15)
3.3.6:函数操作
在C语言中,函数是一种过程,提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。函数调用主要涉及三个要点:(1)传递控制:在调用函数进行某个过程的时候,程序计数器必须设置为调用点代码的起始地址,便于调用完成之后的地址返回。(2)传递数据:在调用函数时,程序需向调用函数提供参数(有时也可不提供),调用函数大部分时候会返回一个值。(3)分配、释放内存:在调用函数时,系统需为函数局部变量分配空间,在函数return之后再释放这些空间。
通过观察汇编程序,我们可以发现函数参数传递使用的寄存器顺序是一定的,64位系统设有6个函数传递寄存器,其顺序依次是:%rdi, %rsi, %rdx, %rcx, %r8, %r9。除此之后再进行参数传递就会通过栈空间。接下来我们将对.s文件中涉及的函数进行分析:
- main函数:(汇编内容见上方图3-3)
main函数传入了两个参数,分别为argc和argv[],分别用寄存器%rdi和%rsi存储。在程序运行时,main函数被自动调用。最后几行表示出main函数的返回通过%eax实现,值为0,对应return 0 。
- exit函数:图3-16
(图3-16)
exit函数在main函数的最后被调用执行,其作用为传入参数1之后执行退出命令。
- sleep函数:图3-17
(图3-17)
sleep函数的汇编代码在.L4段中,对应源程序中的for循环中的sleep函数段。其传入的参数为atoi(argv[3]),在for循环中被调用,作用是使系统休眠。
- getchar函数:图3-18
(图3-18)
getchar函数在.L3段,即主函数段中被调用。作用为读入用户输入的内容。
- printf函数:图3-19
(图3-19)
printf函数具体体现为.LC段中的两个.string,.string中的内容即为pirntf函数打印的内容。其含义已在上文字符串中阐述过,此处略过。
3.3.7:类型转换操作
.s文件中有一处数据类型转换的操作,具体体现在图3-20中
(图3-20)
这里进行类型转换的原因是sleep函数的参数为int值,而argv为字符串数组,所以汇编时用atoi将字符串转化成int型。在.s中用call语句调用atoi函数强制处理该类型转换。
3.4 本章小结
本章的关键词是编译,.i文件通过编译器ccl生成汇编程序.s。汇编语言是C程序经过编译器处理后的一种语言形式,相对于高级程序语言,它更加底层,也更加贴近于机器码。本章通过Ubuntu上具体的实例详细介绍了编译的概念和过程实现。展示了hello.c文件是如何一步步转化为汇编代码的,说明了源程序中的各代码段和汇编语言代码段中的对应关系。
对Hello的编译结果进行重点说明,首先介绍了.s文件中出现的各种文件结构及其含义,例如.text、.global、.data等。之后分析了数字、字符串、数组等数据结构在汇编语言中的形式和含义。最后对.s文件中的各种操作控制(如条件跳转、算术操作、赋值操作等)进行了剖析,说明了各个代码含义和流程。为下一章汇编做下了准备。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:
汇编器(as)将汇编语言(例如hello.s文件)翻译成机器语言指令(例如hello.o文件)的过程称为汇编,其中这个.o文件还是一个可重定位目标程序,可以在下一步继续链接。
作用:
将汇编代码转换为计算机能够理解并执行的二进制机器代码,这个二进制机器代码是程序在本机器上的机器语言的表示。
4.2 在Ubuntu下汇编的命令
命令格式:gcc hello.s -c -o hello.o 或 as hello.s -o hello.o (本章采用前者)
展示如下图4-1
(图4-1)
4.3 可重定位目标elf格式
为了查看.o文件的elf格式,我们可以采用工具readelf,具体命令格式为:readelf -a hllo.o > hello.elf(图4-2)。这样我们就可以在新生成的hello.elf文件中直接查看elf格式内容了。
(图4-2)
hello.elf的内容具体内容分析如下:
- elf头(elf header)(图4-3):elf头描述了生成该文件的系统的字的大小和字节顺序,elf头包含了帮助链接器语法分析和解释目标文件的信息,其中包括elf头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
(图4-3)
- 节头部表(图4-4):包含了文件中出现的各个节的语义、节的类型、位置和大小等信息。节头中的代码是可执行的,但是不能写;数据段和只读数据段都不可执行,而且只读数据段也不可写。节头中包含了许多节(也叫段),有的节之前在3.3.1中介绍过,这里我们再介绍更多之前未提及的节:
(1).rela.text节:一个.text节中位置的列表。
(2).bss节:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量存储在其中。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。
(3).comment节:显示版本控制信息。
(4).note节:注释节详细描述。
(5).eh_frame节:处理异常。
(6).strtab节:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部的节名字。
(7).symtab节:本节用于存放符号表。
(8).shstrtab节:包含节区名称。
(图4-4)
- 重定位节(图4-5):显示各个段引用的外部符号等,在链接时,需要通过重定位节对这些位置的地址进行修改,这里链接器对elf文件的重定位节的操作我们会在之后的下一章链接中介绍。
本程序需要重定位的信息有:.rodata中的模式串,puts,exit,printf,slepsecs,sleep,getchar等符号。
(图4-5)
- 符号表(图4-6):也是.symtab节,其作用是存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明。
(图4-6)
4.4 Hello.o的结果解析
根据要求,使用objdump -d -r hello.o > hello.asm 生成hello.o的反汇编文件hello.asm(图4-7),之后查看hello.asm,并与第3章的 hello.s进行对照分析。
(图4-7)
hello.asm这个反汇编文件的全部内容如下图(图4-8)
(图4-8)
通过与第三章生成的hello.s汇编语言文件进行对照分析,我们可以发现以下几点不同:
- 反汇编语言中mov、add等操作后方无q, b, l等字符后缀。
- 反汇编中利用call调用函数时会直接体现出函数名,且call的目标地址是当前指令的下一条指令,而汇编语句直接利用地址进行call调用。
- 反汇编代码中使用相对偏移地址代替了汇编语句中的标志位。
- 反汇编与汇编在全局变量访问上不同,汇编语言对于全局变量的访问为LC0和sleepsecs(%rip),而在反汇编代码中是$0x0和0(%rip),原因与函数调用一样,全局变量的地址也是在运行时才确定,访问也需要经过重定位。
4.5 本章小结
本章对汇编这一操作进行了详细的介绍,特别是对汇编产生的结果文件(elf等)进行了深入分析。在汇编过程中,hello.s被汇编器变为hello.o文件,此时hello.o已经是二进制文件,可以直接被机器读懂,其中的可重定位条目也为下一阶段——链接做下了准备。
其次,我们解析了可重定位目标elf格式,对它的elf头,节头,符号表等重要组成部分都进行了分析和说明,阐述了其中各个符号、字段的含义。
最后,我们还将.o文件进行了反汇编,将生成的hello.asm文件与之前的.s文件进行对比,体现出了反汇编语句与汇编语句的区别与联系。
到此为止,我们距离生成可执行文件还差最后一步,就是链接,其具体内容我们将在下一章讲到。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:
链接是将各种不同文件的代码和数据部分收集(符号解析和重定位)起来并组合成一个单一文件的过程。
作用:
把可重定位目标文件和命令行参数作为输入,产生一个完全链接的,可以加载运行的可执行目标文件
例如:我们在最初的hello.c程序中调用了printf函数,其printf函数是存在于一个名叫printf.o的已经编译好的文件之中。为了能够正常的调用执行printf函数和生成可执行文件hello,我们就必须通过链接器ld将这个printf.o文件整合到hello.o中。
5.2 在Ubuntu下链接的命令
命令格式: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
如下图5-1
(图5-1)
5.3 可执行目标文件hello的格式
在shell中输入命令:readelf -a hello > hello2.elf(用以区别之前生成的hello.elf文件),这样我们就生成了所需的elf文件,直接打开查看即可。
- elf头(elf header):(图5-2)
(图5-2)
其格式与内容与上一章中的elf文件基本类似。但也有几处差异:
- 类型:由REL(可重定向目标文件)变为EXEC(可执行文件);
- 程序入口点:由0x0(未确定)变为了0x4010f0;
- 程序和节的起始位置和大小都有改变;节个数由14个变为了27个。
- 节头:(由于节头较长,这里我们截取部分分析,见图5-3)
(图5-3)
由上图可见,hello2.elf的节头结构和内容与上一章的hello.elf基本相同。都展示了节的类型、位置、偏移量和大小等信息。但是相对于hello.elf,我们这次新生成的elf文件内容明显增多了。节从14个变成了27个。
- 符号表:(由于节头较长,这里我们截取部分分析,见图5-4)
(图5-4)
由上图可见,hello2.elf的符号表结构和内容与上一章的hello.elf基本相同。保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。但hello2.elf相对而言内容更多。
- Dynamic section:(图5-5)
(图5-5)
Dynamic section中包含很多信息,比如你需要的动态库,以及是否立即加载以及符号表等。在got表的第一项指像.Dynamic,并且延迟绑定那个的时候需要传入.Dynamic的对象地址,因为里面包含解析函数地址的上下文。
- 程序头:(图5-6)
(图5-6)
程序头在程序执行的时候被使用,作用是告诉链接器运行时应该加载的内容并提供动态链接的信息,提供了各段在虚拟地址空间和物理地址空间的大小,位置,标志,访问权限和对齐等信息。
使用edb打开我们生成的可执行文件hello,通过edb的Data Dump窗口查看加载到虚拟地址中的hello程序。(图5-7)
(图5-7)
通过观察上图我们可以发现,hello文件的虚拟地址空间是从0x401000-0x402000。我们通过查阅虚拟空间相关资料可以知道,一个linux进程的虚拟内存结构如下图(图5-8),由低地址0x4000000开始向高地址依次是.text, .data, .bss, 运行时堆,用户栈,内核代码数据等等。
(图5-8)
为了清楚每个段在虚拟内存地址中的具体位置,我们还要结合图5-6中的VirtAddr查看,在图5-6中一共包含7个段:
1. PHDR保存程序头表。
2. INTERP指定在程序已经从可执行文件映射到内存之后,必须调用的解释器(如动态链接器)。
3. LOAD表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串)、程序的目标代码等。
4. DYNAMIC保存了由动态链接器使用的信息。
5. NOTE保存辅助信息。
6. GNU_STACK:权限标志,标志栈是否是可执行的。
7. GNU_RELRO:指定在重定位结束之后那些内存区域是需要设置只读。
通过Data Dump查看虚拟地址段0x600000~0x602000,在0~fff空间中,与0x400000~0x401000段的存放的程序相同,即在fff之后存放的是.dynamic~.shstrtab节
在Shell中输入命令objdump -d -r hello > hello2.asm生成反汇编文件hello2.asm(便于与hello.asm区分)。接下来我们将把hello2.asm与第四章中生成的hello.o.asm文件进行比较,并分析主要的不同之处:
这里我们由于hello2.asm长度较长,我们截取一部分展示(图5-9):(图5-9)
经过比较,二者主要有以下不同:
- 函数数量明显增加:在hello2.asm新增了plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。
- 节数量明显增加:hello中增加了.init和.plt节,和一些节中定义的函数。
- hello.asm中相对偏移地址变成了hello2.asm中的虚拟内存地址。
- hello2.asm中没有重定位条目,是因为在链接这一步已经完成文件重定位。
综上我们可以总结出重定位的主要过程步骤:(1)合并相同的节:ld将所有相同类型的节合并成为同一类型的新节,例如所有文件的.data节合并成一个新的.data节,合并完成后该新节即为可执行文件hello的.data节。(2)确定地址:之后ld再分配内存地址赋给新的节以及符号。地址确定后全局变量、指令等均具有唯一的运行时地址。
使用gdb调试工具中的rbreak指令可以直接查看hello内部每个函数的断点,即能查看函数运行流程及其地址。(图5-10)
(图5-10)
5.7 Hello的动态链接分析
首先通过查看之前生成的hello2.elf表找到.got段(图5-11),发现got的地址为0x403ff0
(图5-11)
进入edb中找到该地址,并设置断点,在运行do_init前后查看got表的变化,可以发现got表在调用前后发生了改变,GOT[1]指向重定位表(.plt节需要重定位的函数运行时地址),作用是确定调用函数的地址,GOT[2]指向动态链接器ld-linux.so运行时地址。这样,接下来执行程序的过程中,就可以使用过程链接表PLT和全局偏移量表GOT进行动态链接。
5.8 本章小结
本章主要介绍了链接的相关知识。链接是生成可执行目标文件的最后一步,它的主要步骤分为符号解析和重定位。本章就重定位展开了深入的说明,给出了hello程序中elf格式文本hello2.elf,据此分析了hello2.elf与hello.elf的异同,对重定位的过程和步骤给予了解释说明,介绍了hello文件的虚拟地址空间组成。自从执行hello文件开始,链接的任务就结束了,接下来hello就是一个进程,我们也将从进程管理的角度深入探究hello程序的进程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:
进程的经典定义就是一个执行中程序的实例。在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行 我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
作用:
每次运行程序时,shell创建一新进程,在这个进程的上下文切换中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。
进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:
Shell 是一个用C语言编写的交互型应用程序,代表用户运行其他程序。Shell 应用程序提供了一个界面,用户可以通过这个界面进行系统的基本操作,访问操作系统内核的服务。
Shell的处理流程:
- 从Shell终端读入输入的命令。
- 切分输入字符串,获得并识别所有的参数
- 若输入参数为内置命令,则立即执行
- 若输入参数并非内置命令,则调用相应的程序为其分配子进程并运行
- 若输入参数非法,则返回错误信息
- 处理完当前参数后继续处理下一参数,直到处理完毕
6.3 Hello的fork进程创建过程
在输入命令执行hello后,父进程如果不是内部指令,即会通过fork函数创建子进程。子进程与父进程近似,并得到一份与父进程用户级虚拟空间相同且独立的副本——包括数据段、代码、共享库、堆和用户栈。父进程打开的文件,子进程也可读写。二者之间最大的不同或许在于PID的不同。fork函数只会被调用一次,但会返回两次,在父进程中,fork返回子进程的PID,在子进程中,fork返回0。
调用函数fork创建新的子进程之后,子进程会调用execve函数,在当前进程的上下文中加载并运行一个新程序hello。execve 函数不返回,它将删除该进程的代码和地址空间内的内容并将其初始化,然后通过跳转到程序的第一条指令或入口点来运行该程序,将私有的区域映射进来。这个函数是在系统目标文件ctrl.o中定义的,对所有的c程序都一样。_start函数调用系统启动函数,_libc_start_main,该函数定义在libc.so里,初始化环境,调用用户层的main函数,处理main函数返回值,并且在需要的时候返回给内核。
6.5 Hello的进程执行
1. 用户模式和核心模式
处理器通常是用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时也叫超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器。改变模式位,或者发起一个IO操作。也不允许用户模式中的进程直接引用地址空间中内核区的代码和数据。任何这样的尝试都将导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
2. 上下文
上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。下图(图6-1)就是一个上下文切换的例子,进程A初始运行在用户模式中,直到它通过执行系统调用read陷入到内核。内核中的陷阱处理程序请求来自磁盘控制器的DMA传=传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。
(图6-1)
3.进程时间片:一个进程执行它的控制流的一部分的每一时间段,多任务也称为时间分片
4.调度的过程:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。
6.6 hello的异常与信号处理
1.正常运行状态:分别展示了不足三个参数和满足三个参数的情况(设定休眠为5s)(图6-2)
(图6-2)
2.乱按键盘的情况:会把回车之前的字符串当作命令,在hello结束之后尝试运行(图6-3)
(图6-3)
3.Ctrl + C:(图6-4)
从键盘输入此命令会让操作系统内核发送一个SIGINT信号给到前台进程组中的每个进程,结果是终止前台进程,通过ps命令发现这时hello进程已经被回收。
(图6-4)
4.Ctrl + Z:(图6-5)
与上方Ctrl+C类似,但是不同的是发送的是SIGSTP信号,结果是挂起进制不终止,在挂起之后还可以执行jobs等指令,输入fg可以取消挂起,继续执行。
(图6-5)
5.pstree查看进程树状图:(图6-6)
在Shell中输入pstree命令,可以将所有进程以树状图显示(图6-6仅展示部分)
(图6-6)
6.Kill指令杀死指定进程:(图6-7)
(图6-7)
6.7本章小结
本章重点介绍了进程,以及Shell的基本概念和使用。主要以hello可执行文件的进程创建、终止、回收等操作为例,展示了linux下进程的相关实践操作。
此外,本章还介绍了许多有关信号的知识,重点介绍了系统对各种信号的处理,例如Ctrl+C发出SIGINT信号终止进程、Ctrl+Z发出SIGSTP信号挂起进程等。每种信号都有不同的默认行为,系统也有相应的不同的处理机制。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1 逻辑地址:
逻辑地址由选择符和偏移量两部分组成。要经过寻址方式的计算或变换才得到内存储器中的物理地址。即hello中的偏移地址。
7.1.2 线性地址:
线性地址由逻辑地址经过转换得到。先用段选择符到全局描述符表中取得段基址,再加上段内偏移量,即得到线性地址。在IA64中,我们认为线性地址就是逻辑地址。
7.1.3 虚拟地址:
在IA32中,虚拟地址需要经过到线性地址再到物理地址的变换。IA6中,我们认为虚拟地址就是逻辑地址,也是线性地址。
7.1.4 物理地址
物理地址是寻址物理内存的地址,是地址变换的最终结果地址。系统可以根据物理地址直接访存。如果启用了分页机制,那么hello的线性地址会使用页目录和页表中的项变换成hello的物理地址;如果没有启用分页机制,那么hello的线性地址就直接成为物理地址了。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由段标识符和段内偏移量两部分组成。其中段标识符是一个16位长的字段组成,也叫段选择符,其前13位是一个索引号。后面三位包含一些硬件细节。如下图(图7-1)。
(图7-1)
由上图可以知道,索引号就是段选择符的前13位。不同的段有不同的索引号,我们主要通过索引号寻找段。
当系统给定一个完整的逻辑地址段选择符+段内偏移地址,时我们就可以开始进行逻辑地址到线性地址的变换(即段式管理):
1.看段选择符的T1=0还是1,知道当前要转换是全局段描述符表(GDT)中的段,还是局部段描述符表(LDT)中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
2.拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,即基地址就知道了。
3.把基址加上偏移量,就是要转换的线性地址了
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(VA)到物理地址(PA)的转化是CSAPP书中的重点,它是地址翻译过程中最重要的一部分。这一过程主要由页式管理负责,其总体过程如图7-2所示。
在linux系统下,每个段被分割虚拟页(VP)大小的块,用来作为进行数据传输的单元,每个虚拟页大小为4KB。MMU(内存管理单元)负责地址翻译,它使用存放在物理内存中的页表将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
当系统确定VPN和VPO的位数之后,就可以通过页表基址寄存器在页表中获得条目PTE,一条PTE中包含有效位、权限信息、物理页号等信息。若有效位是0 + NULL则代表没有在虚拟内存空间中分配该内存,如果是有效位0 + 非NULL,则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中,如果有效位是1则代表该内存已经缓存在了物理内存中,可以得到其物理页号PPN,与虚拟页偏移量共同构成物理地址PA。
(图7-2)
7.4 TLB与四级页表支持下的VA到PA的变换
首先介绍对一些有关地址翻译的器件进行初步介绍:
- TLB:后备缓冲器,也被翻译为页表缓存、转址旁路缓存,为CPU的一种缓存,由存储器管理单元用于改进虚拟地址到物理地址的转译速度。TLB具有固定数目的空间槽,用于存放将虚拟地址映射至物理地址的标签页表条目。其搜索关键字为虚拟内存地址,其搜索结果为物理地址。如果请求的虚拟地址在TLB中存在,TLB将给出一个非常快速的匹配结果,之后就可以使用得到的物理地址访问存储器。如果请求的虚拟地址不在 TLB 中,就会使用标签页表进行虚实地址转换,而标签页表的访问速度比TLB慢很多。有些系统允许标签页表被交换到次级存储器,那么虚实地址转换可能要花非常长的时间。
- 页表条目:页表就是一个页表条目(Page Table Entry,PTE)的组成的数组。每个 PTE 至少由一个有效位(valid bit)和一个N位地址字段(PFN)组成。有效位表明该虚拟页是否被缓存到内存中。地址字段指向页的起始位置。
接下来是VA到PA的具体过程的介绍,这里以Core i7为例(流程图见图7-3):
假设虚拟地址空间 48 位,物理地址空间 52 位,页表大小 4KB,4 级页表。TLB 4 路 16 组相联。则我们可以知道:由一个页表大小 4KB,一个 PTE 条目8B,一共4个页表共使用36位二进制索引,故 VPN 共 36 位,则 VPO 12 位;因为 TLB 16 组,则TLBI 4 位,因为 VPN 36 位,所以 TLBT 32 位。
CPU 产生虚拟地址 VA,VA 传送给 MMU,MMU 使用前 36 位 VPN 作为 TLBT(前 32 位)+TLBI(后 4 位)向 TLB 中匹配,如果命中,则得到 PPN (40bit)与 VPO组合成 PA。 如果 TLB 中没有命中,MMU 向页表中查询, VPN1确定在第一级页表中的偏移量,查询出 PTE,如果在物理内存 中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到 PPN,与 VPO 组合成 PA,并且向 TLB 中添加条目。如果查询 PTE 的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
(图7-3)
7.5 三级Cache支持下的物理内存访问
我们依旧以Core i7为例,三级Cache进行物理访存的流程图依然用上图(图7-3)为例:
经过上一节地址翻译,我们已经得到了物理地址PA,根据Cache的相关结构,系统会将PA分为CT(标记位),CS(组号),CO(偏移量)三部分。根据CS寻找到正确的组,比较每一个Cache行是否标记位有效以及CT是否相等。如果命中就直接返回想要的数据,如果不命中,就依次去L2,L3,主存判断是否命中,当命中时,将数据传给CPU同时更新各级cache的Cache行(如果cache已满则要采用换入换出策略)。
这里我们再对上一自然段中Cache行满时的替换策略做一些说明:
常见的替换算法有:随机替换算法,先进先出替换算法(FIFO,First In First Out)、最不经常使用替换算法(LFU,Least FrequencyUsed)
1.随机替换算法
Cache 在发生替换时随机的选取一行换出。这种替换策略速度快、硬件实现简单,但是有可能替换最近访问行而降低 Cache 的命中率。
2.先进先出替换策略
将替换规则与访问地址的历史顺序相关联,每次都把最先放入的一行数据块被替换出,但是该设计方法与 Cache 设计原理的局部性原理相悖,并不能反应数据块的使用频率。
3.最不经常使用替换算法
LFU 是替换最近一段时间内长期未被访问且访问频率最低的 Cache 行数据,这种策略是通过设置计数器来完成计数每个数据块的使用频率。每行设置一个从 0 开始的计数器,计数器在每次被访问时自增 1。当由于发生 Cache 缺失时,需要替换计数值最小行,同时清零这些行的计数器。这种替换算法只计数到两次特定时间内,不能严格反映出近期访问情况,与局部性原理不符。
7.6 hello进程fork时的内存映射
fork函数的相关知识已经在之前的第六章中详细介绍过,这里我们就着重介绍关于fork的内存映射。
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。
它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。
7.7 hello进程execve时的内存映射
execve函数加载并运行hello需要以下几个步骤:
- 删除已存在的用户区域:删除当前进程hello虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域:为新程序的代码、数据、bss和栈区域创建新的私有的、写时复制的区域结构。其中,代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
- 映射共享区域:若hello程序与共享对象或目标(如标准C库libc.so)链接,则将这些对象动态链接到hello程序,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器:最后,execve设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
关于加载器是如何映射用户地址空间的,这里也有一张示意图作参考:(图7-4)
(图7-4)
7.8 缺页故障与缺页中断处理
当发生缺页故障时,硬件和操作系统内核会协同进行缺页的处理。大体流程如图7-5所示:
(图7-5)
(1)处理器生成一个虚拟地址,并把它传送给 MMU
(2)MMU 生成 PTE 地址,并从高速缓存/主存请求得到它。
(3)高速缓存/主存向 MMU 返回 PTE
(4)PTE 中的有效位是零,所以 MMU 触发了一次异常,传递 CPU 中的控制到操作系统内核中的缺页异常处理程序。
(5)缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。
(6)缺页处理程序页面调入新的页面,并更新内存中的 PTE。
(7)缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU 将引起缺页的虚拟地址重新发送给 MMU。
同时我们值得注意的是,缺页故障是一种异常,所以它也会造成异常中断处理。当MMU中查找页表时发现所需地址不在内存中,就会发生故障。其处理流程大致如下图7-6所示。
(图7-6)
7.9动态存储分配管理(课程内容未涉及,本节跳过)
(以下格式自行编排,编辑时删除)
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
7.10本章小结
本章主要介绍了存储管理的各种相关知识。包括虚拟地址(VA)、物理地址(PA)以及这二者的转换方式——地址翻译。此外还有fork、execve等函数的内存映射的内容。最后还介绍了一些Cache的寻址方法以及缺页时的故障处理等。
(第7章 2分)
第8章 hello的IO管理
(课程未涉及,本章跳过)
8.1 Linux的IO设备管理方法
(以下格式自行编排,编辑时删除)
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
(以下格式自行编排,编辑时删除)
8.3 printf的实现分析
(以下格式自行编排,编辑时删除)
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
(以下格式自行编排,编辑时删除)
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
(以下格式自行编排,编辑时删除)
(第8章1分)
结论
hello.c虽然是一个简单的小程序,但是它在计算机系统中从预处理到最后进程回收的一生也可谓是波澜壮阔。在它被回收进程的最后一刻,它或许会回忆自己的一生:
- 预处理:预处理器cpp会将hello.c中的各种头文件插入文本中,删除多余空白字符和注释,生成hello.i,等待后续处理。
- 编译:hello.i文件在编译器ccl的作用下被翻译成汇编语言文本hello.s。
- 汇编:将hello.s在汇编器as的作用下变为可重定位目标文件hello.o。
- 链接:将hello.o与可重定位目标文件(例如printf.o文件)和动态链接库链接成为可执行目标程序。
- 运行:在shell中输入: ./hello即运行可执行文件hello。
- 进程创建:系统会使用fork函数为hello产生一个进程。
- 执行指令:CPU按照二进制机器码一步步执行hello中的指令。
- 存储管理:MMU将VA翻译为PA,PA再进一步访存。此外还有fork和execve函数进行内存的映射。
- 信号处理:在运行的过程中,系统会处理各种可能出现的信号(比如键盘输入的Ctrl+C或Ctrl+Z等),针对信号的默认行为采取不同的操作。
- 进程回收:执行完毕最后return的hello程序会被shell中的父进程回收,删除它所有的相关数据,从此结束了它的一生。
作为一个将来的程序员,理解各种语言和编写目标代码只是基本的要求。更重要的是,我们需要理解程序在计算机系统中真正的运行过程,知道它每一步的生成文件和背后的原理,了解程序底层的运行逻辑。这样会让我们可以面对系统,面对CPU写出更优化的代码,也可以解决更多的看似匪夷所思的bug。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件 | 作用 |
hello.c | 源文件 |
hello.i | 预处理后生成的文件 |
hello.s | 编译后生成的文件 |
hello.o | 汇编后生成的可重定位目标程序 |
hello | 最终生成的可执行目标文件 |
hello.asm | hello.o反汇编生成的文件 |
hello.elf | hello.o的elf格式文件 |
hello2.asm | hello反汇编生成的文件 |
hello2.elf | hello的elf格式文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 《深入理解计算机系统》 Randal E.Bryant David R.O’Hallaron 机械工业出版社
[2] Shell知识大全_shell学习资料_大数据点滴的博客-CSDN博客
[3] 动态内存分配_努力啃C语言的小李的博客-CSDN博客
[4] C语言Printf函数深入解析_printf函数原型_猿来不是梦的博客-CSDN博客
[5] ELF格式解读 Dynamic节_elf .dynamic_不会写代码的丝丽的博客-CSDN博客
(参考文献0分,缺失 -1分)