该论文以hello.c文件为研究对象,结合《深入理解计算机系统》书中的内容与课上老师的讲授,在Ubuntu系统下对hello程序的整个生命周期进行了研究,通过对hello.c程序的深入研究,把本学期计算机系统课程所学知识梳理与回顾了一遍,加深了对计算机系统的了解。
关键词:底层原理;代码周期;计算机系统;编译;加载;链接;终止;回收……
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
P2P(From Program to Process):将Hello从程序文件到执行中的文件的过程。在这其中,hello.c依次经过了预处理,编译,汇编,链接成为可执行文件(Program)。我们打开命令行窗口中,打开文件路径,输入./hello就可以执行该文件。
020:shell首先fork一个子进程,然后通过execve加载并执行hello,映射虚拟内存,进入程序入口后将程序载入物理内存,进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。即,从0开始,以0结束,为020。
1.2 环境与工具
Intel Core i7-8550U X64 CPU; 1.80GHz; 16G RAM; 128G SSD + 1T HDD;
Windows 10 64位; VMware 15 pro; Ubuntu-18.04.3 LTS;
vim/gedit+gcc; gdb; edb; objdump; readelf; Visual Studio Code; wxHexEditor
1.3 中间结果
中间结果文件 | 文件作用 |
hello.i | 预处理得到的文件 ASCII码的中间文件 |
hello.s | ASCII汇编语言文件 |
hello.o | as得到可重定位目标文件 |
Disas_hello.s | 反汇编得到的文本文件 |
elf.txt | 用readelf读取hello.o得到的ELF格式信息 |
hello | ld得到可执行目标文件 |
hello_5.3.elf | hello的elf文件 |
hello_objdump.s | hello的反汇编文件 |
1.4 本章小结
本章对hello进行了一个总体的概括,首先介绍了P2P、020的意义和过程,介绍了作业中的硬件环境、软件环境和开发工具,最后简述了从.c文件到可执行文件中间经历的过程。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,试图解释为预处理指令(preprocessing directive) 。
ISO C/C++要求支持的预处理指令包括:
#if、 #ifdef、 #ifndef、 #else、 #elif、 #endif(条件编译)、 #define(宏定义)、 #include(源文件包含)、 #line(行控制)、 #error(错误指令)、 #pragma(和实现相关的杂注)以及单独的#(空指令)。
预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。
预处理的作用:
宏定义:宏定义将代码中的宏名与实际字符串进行替换,可以增强代码的可读性;
文件包含处理:将include头文件复制到#处替换,减少重复的工作,加强代码模块化;
条件编译处理:决定哪些具体代码会被编译处理。
2在Ubuntu下预处理的命令
命令 gcc -E hello.c -o hello.i
输入后如图2-1所示
图2-1 预处理命令执行结果
2.3 Hello的预处理结果解析
在Linux下打开hello.i文件,可以发现hello.i程序已经拓展为3110行,行数比起hello.c文件大幅增加。其中, hello.c中的main函数相关代码在hello.i程序中对应着3094行到3110行。
图2-2 打开hello.i文件
在main函数内代码出现之前是大段的头文件 stdio.h unistd.h stdlib.h 的依次展开。展开的具体流程概述如下(以stdio.h为例):CPP先删除指令#include <stdio.h>,并到Ubuntu系统的默认的环境变量中寻找 stdio.h,最终打开路径/usr/include/stdio.h下的stdio.h文件。若stdio.h文件中使用了#define语句,则按照上述流程继续递归地展开,直到所有#define语句都被解释替换掉为止。除此之外,CPP还会进行删除程序中的注释和多余的空白字符等操作,并对一些值进行替换。
2.4 本章小结
本章主要介绍了预处理(包括头文件的展开、宏替换、去掉注释、条件编译)的概念及作用,以及Linux下预处理的两个指令,同时结合Ubuntu系统下hello.c文件实际预处理之后得到的hello.i程序并对预处理结果进行了解析,详细了解了预处理的内涵。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
将代码转换为汇编指令或者机器指令的过程。即编译器通过词法分析和语法分析,将合法指令翻译为等价的汇编代码。(不同语言通过编译后的到的文件内容可能相同)
3.2 在Ubuntu下编译的命令
参考PPT,在Ubuntu系统下,进行预处理的命令为:
gcc –m64 –no-pie –fno-PIC -S -o hello.i hello.s
图3-1 编译命令执行结果
3.3 Hello的编译结果解析
对hello.s文件整体结构分析如下:
指令 | 含义 |
.file | C文件声明 |
.text | 代码段 |
.globl | 声明全局变量 |
.data | 已初始化的全局和静态C变量 |
.align 4 | 声明对指令或者数据的存放地址进行对齐的方式 |
.type | 指明函数类型或对象类型 |
.size | 声明变量大小 |
.long .string | 声明long型、string型数据 |
.section .rodata | 只读数据段 |
在hello.s中,涉及的数据类型包括以下三种:整数,字符串,数组。下面对每种数据类型依次进行分析。
- 整数
在hello.s中,涉及的整数有:
- int sleepsecs
查看C语言文件可知,sleepsecs为int型全局变量,已被初始化赋值2.5。
图3-2 初始化赋值sleepsecs
经过编译阶段得到的hello.s文件中,编译器在.text段中将sleepsecs声明为全局变量,在.type段声明其为object类型,在.size段声明其长度为4,设置其值为2。具体情况如下:
图3-3 声明变量长度
- int i
编译器将局部变量存储在寄存器或者栈空间中。i作为函数内部的局部变量,并不占用文件实际节的空间,只存在于运行时栈中。对于i的操作就是直接对寄存器或栈进行操作。
在hello.s中我们可以看出,i占据了4字节的地址空间:
图3-4 i字节地址
- int argc
argc作为第一个参数传入,下图结合汇编代码分析argc保存位置。
图3-5 argc保存位置
- 立即数3
立即数3在汇编语句中直接以$3的形式出现
- 字符串
1.“Usage: Hello 学号 姓名!\n”
第一个printf传入的输出格式化参数,存放在只读数据段.rodata中,可以发现字符串被编码成utf-8格式,一个汉字在utf-8编码中占三个字节,一个\代表一个字节。如图所示。
图3-6 Usage: Hello 学号 姓名!\n保存位置
2."Hello %s %s\n"
第二个printf传入的输出格式化参数,存放在只读数据段.rodata中。如图所示。
图3-7 Hello %s %s\n保存位置
- 数组
char *argv[]
argv单个元素char*大小为8位,argv指针指向已经分配好的、一片存放着字符指针的连续空间,起始地址为argv。下图分析了argv传入函数时存储的位置
main函数中访问数组元素argv[1],argv[2]时,按照起始地址argv大小8位计算数据地址取数据,在hello.s中,使用两次(%rax)(两次rax分别为argv[1]和argv[2]的地址)取出其值。
图3-8 sleepsecs赋值信息
3.3.3赋值
- int sleepsecs=2.5
在C语言源程序中包含一个隐式类型转换:将2.5赋值给一个int类型,结果为2。体现在hello.s中,直接在.data节中将sleepsecs声明为值2的long类型数据。
- int i
对局部变量的赋值在汇编代码中通过mov指令完成。具体使用哪条mov指令由数据的大小决定,如图所示:
表格 3 mov指令的后缀
后缀 | b | w | l | q |
大小(字节) | 1 | 2 | 3 | 4 |
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]=s*r[%rax](有符号) |
mulq s | r[%rdx]:r[%rax]=s*r[%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 |
在hello.s中,具体涉及的算数操作包括:
- subq $32, %rsp:开辟栈帧
- addq $16, %rax:修改地址偏移量
- addl $1, -4(%rbp):实现i++的操作
图3-9 hello.s中涉及的算数操作
3.3.6 关系操作
总结进行关系操作的指令如下:
指令 | 基于 | 解释 |
CMP S1, S2 | S2-S1 | 比较设置条件码 |
TEST S1, S2 | S1&S2 | 测试设置条件码 |
SET** D | D=** | 按照**将条件码设置D |
J** | —— | 根据**与条件码进行跳转 |
程序中涉及的关系运算如下:
(1)argc!=3;是在一条件语句中的条件判断:argc!=3,进行编译时,这条指令被编译为:cmpl $3,-20(%rbp),同时这条cmpl的指令还有设置条件码的作用,当根据条件码来判断是否需要跳转到分支中。
(2)i < 8,在hello.c作为判断循环条件,在汇编代码被编译为:cmpl $9,-4(%rbp),计算 i-7然后设置 条件码,为下一步 jle 利用条件码进行跳转做准备。
3.3.7控制转移
首先设置条件码,然后根据条件码来进行控制转移。hello.c中有以下控制转移指令:
(1)判断i是否为3,如果i等于3,则不执行if语句,否则执行if语句:
- for(i = 0;i < 8;i++),通过每次判断i是否满足小于8来判断是否需要跳转至循环语句中:
第一处画圈:i赋初值0,然后无条件跳转至判断条件的代码中
第二处画圈:判断i是否符合循环的条件,符合直接跳转至循环体的内部,即L4
3.3.8数组操作
c源程序中的数组操作出现在循环体for循环中,每次循环中都要访问argv[1],argv[2]这两个内存。在翻译时,argv[]先是被存在用户栈中,再使用基址加偏移量寻址访问argv[1],argv[2]。
argv[1]: 数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上偏移量$16,再将该位置内容放在%rdx中,成为下一个函数的第一个参数。
argv[2]: 数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上偏移量$8,再将该位置内容放在%rdi中,成为下一个函数的第二个参数。
程序中涉及的数组为char *argv[],即函数的第二个参数。在hello.s中,其首地址保存在栈中。访问时,通过寄存器寻址的方式访问。
3.3.9 函数操作
调用函数时有以下操作:
(假设函数P调用函数Q)
- 传递控制:进行过程 Q 的时候,程序计数器设置为 Q 的代码的起始地址;返回时,把程序计数器设置为 P 中调用 Q 后面那条指令的地址。
- 传递数据:P 能够向 Q 提供一个或多个参数,而Q 能够向 P 中返回一个返回值。
(3) 分配和释放内存:开始时,Q 可能需要为局部变量分配空间,而在返回前需释放这些空间。
hello.c程序中涉及的函数操作有:
main函数,printf,exit,sleep ,getchar函数
main函数的参数是argc和argv;两次printf函数的参数恰好是那两个字符串
exit参数是1,sleep函数参数是atoi(argv[3])
函数的返回值存储在%eax寄存器中。
3.4 本章小结
围绕hello.s文件,介绍编译概念,分析了hello.o文件中代码结构以及文件结构,对其汇编语言里的数据类型,条件控制,数组,字符串等等在汇编语言中的操作进行了较为详细的解释。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:将汇编语言转化为可重定位目标文件,生成hello.o文件,是二进制编码文件,包含程序的机器码指令,就是把汇编语言转化为机器语言
作用:转化为机器可识别的语言,并将相关指令以可重定位目标程序格式保存在.o文件中
4.2 在Ubuntu下汇编的命令
命令 gcc -c hello.s -o hello.o
4-1编译命令执行结果
图4-2 使用wxHexEditor打开hello.o
4.3 可重定位目标elf格式
ELF(Executable and Linkable Format) 可执行,可链接文件格式,每个section再映射时的长度都是系统页的整数倍,
存在segment 和section两种试图,segment是section的集合
操作系统往往十一页为基本单位来管理内存分配,一般页的大小为4096B,4KB大小。内存管理的权限管理的粒度也是以页为单位,页内的内存具有相同的权限等属性,并且操作系统对页的管理往往追求高效和高利用率,ELF加载到内存中,再映射时,是以系统的页长度为单位,每个section映射长度是系统页的整数倍,如果Section的长度不是整数倍,导致多余的部分也将占用一个页。当一个ELF文件具有很多Section,就会导致内存浪费严重,这样可以减少内部的碎片,节省空间,显著提高内存利用利用率。所以在执行时会集合成Segment。
图4-3关于EFL的详细解释
使用命令 readelf -a hello.o > hello_o_elf.txt,读取hello.o文件的ELF格式至hello_o_elf_txt中。
以 16字节序列 Magic 开始,其描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
图4-4 EFL头信息
节头部表包括节的全部信息,如图4-4所示,各个节的名称及内容如下:
节名称 | 包含内容 |
.text | 已编译程序的机器代码 |
.rela.text | 一个.text节中位置的列表,链接器链接其他文件时,需修改这些内容 |
.data | 已初始化的全局和静态C变量 |
.bss | 未初始化的全局和静态C变量和所有被初始化为0的全局或静态变量 |
.rodata | 只读数据段 |
.comment | 包含版本控制信息 |
.note.GNU-stack | 包含注释信息,有独立的格式 |
.symtab | 符号表,存放程序中定义和引用的函数和全局变量信息 |
.strtab | 字符串表,包括.symtab和.debug节中的符号表以及节头部中的节名字 |
.shstrtab | 包含节区名称 |
图4-5 EFL节头信息
重定位是将EFL文件中的未定义符号关联到有效值的处理过程。在hello.o中,对printf,exit等函数的未定义的引用和全局变量(sleepsecs)替换为该进程的虚拟地址空间中机器代码所在的地址。
图4-6 EFL重定位信息
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
图4-7 EFL符号表信息
4.4 Hello.o的结果解析
使用 objdump -d -r hello.o > helloo.objdump获得反汇编代码。
图4-8 获得反汇编代码
对比hello.s中main函数与反汇编后main函数如图所示。
图4-9 hello.s 中main函数以及反汇编后得到的main函数
通过对比hello.asm与hello.s可知,两者在如下地方存在差异:
- 分支转移:
在hello.s中,跳转指令的目标地址直接记为段名称,如.L2,.L3等。而在反汇编得到的hello.asm中,跳转的目标为具体的地址,在机器代码中体现为目标指令地址与当前指令下一条指令的地址之差。
图4-10 分支转移
2.函数调用:
在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,在链接后再进一步确定。
图4-11 函数调用
3.全局变量访问
在.s文件中,访问.rodata(printf中的字符串),使用段名称+%rip,在反汇编代码中0+%rip,因为.rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
图4-12 全局变量访问
4.5 本章小结
本章对hello.s的结果进行了阐述,分析了可重定位文件的ELF头、节头部表、
符号表和可重定位节,比较了hello.s和hello.o反汇编代码的区别,分析了机器语
言的构成与汇编语言的映射关系。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是通过链接器(Linker)将文件中调用的各种函数跟静态库及动态库链接,并将它们打包合并形成目标文件,即可执行文件。可执行文件可以被加载(复制)到内存并执行。
通过链接可以实现将头文件中引用的函数并入到程序中,解析未定义的符号引用,将目标文件中的占位符替换为符号的地址。完成程序中各目标文件的地址空间的组织,这可能涉及重定位工作。
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.3 可执行目标文件hello的格式
在Shell中输入命令 readelf -a hello > hello2.elf 生成 hello 程序的 ELF 格式文件,保存为hello2.elf(与第四章中的elf文件作区分):
图5-2 生成hello2.elf文件
打开hello2.elf,分析hello的ELF格式如下:
- ELF 头(ELF Header)
hello2.elf中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以 描述了生成该文件的系统的字的大小和字节顺序的16字节序列 Magic 开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与hello.elf相比较,hello2.elf中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。
图5-3 hello2.efl头信息
- 节头
hello2.elf中的节头包含了文件中出现的各个节的语义,包括节的类型、位置、偏移量和大小等信息。与hello.elf相比,其在链接之后的内容更加丰富详细(此处仅截取部分展示)。
图5-4 hello2.efl节头信息
- 程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
图5-5 hello2.efl程序头信息
- 动态磁盘
图5-6 动态磁盘信息
- 符号表
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明(此处仅截取部分展示)。
图5-7 符号表信息
5.4 hello的虚拟地址空间
使用edb加载hello,查看进程的虚拟地址空间各段信息,如图所示。
图5-8 hello节头表与虚拟地址空间各段
5.5 链接的重定位过程分析
执行命令:objdump -d -r hello > hello.objdump 得到hello的反汇编文件hello2.asm。与第四章中生成的hello.o.asm文件进行比较,其不同之处如下:
- 链接后函数数量增加。链接后的反汇编文件hello2.asm中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。
图5-9 链接后的函数
- 函数调用指令call的参数发生变化。在链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。
图5-10 call指令的函数
- 跳转指令参数发生变化。在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
图5-11 跳转指令的函数
5.6 hello的执行流程
使用edb执行hello,查看从加载hello到_start,到call main,以及程序终止的所有过程。下表列出其调用的程序名称与各个程序地址。
程序名称 | 程序地址 |
ld-2.27.so!_dl_start | 0x7fce:8cc38ea0 |
ld-2.27.so!_dl_init | 0x7fce:8cc47630 |
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 |
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 |
5.7 Hello的动态链接分析
编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,在GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数,在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。
.got与.plt节保存着全局偏移量表GOT,其内容从地址0x601000开始。通过edb查看,在dl_init调用前,其内容如下:
图5-12 调用前的情况
在调用后,其内容变为:
图5-13 调用后的情况
比较可以得知,0x601008~0x601017之间的内容,对应着全局偏移量表GOT[1]和GOT[2]的内容发生了变化。GOT[1]保存的是指向已经加载的共享库的链表地址。GOT[2]是动态链接器在ld-linux.so模块中的入口。这样,接下来执行程序的过程中,就可以使用过程链接表PLT和全局偏移量表GOT进行动态链接。
5.8 本章小结
本章温习了在linux中链接的过程。通过查看hello的虚拟地址空间,对比hello与hello.o的反汇编代码,进一步掌握了链接与中重定位的过程;遍历了整个hello的执行过程,在最后对hello进行了动态链接分析
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
-
-
- 进程的概念
-
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,包括代码段、数据段、和堆栈区。代码段存储CPU执行的代码,数据段存储变量和进程执行期间使用的动态分配的内存,堆栈区存储活动过程调用的指令和本地变量。
-
-
- 进程的作用
- 每次用户向shell输入一个可执行目标文件的名字运行时,shell就会创建一个新的进程,然后在这个进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。
- 提供给应用进程两个关键抽象:
- 进程的作用
-
- 一个独立的逻辑控制流,好像程序可以独使用处理器。
- 一个私有的地址空间,好像程序独占整个内存系统。
6.2 简述壳Shell-bash的作用与处理流程
- Shell-bash的作用
shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。
- Shell的处理流程
shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,或者是自由软件)。然后shell在可执行程序的目录列表里寻找这些应用程序。如果键入的命令不是一个内部命令并且在列表里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。
6.3 Hello的fork进程创建过程
在终端中输入./hello 1180300811 sunxiao,shell判断它不是内置命令,于是会加载并运行当前目录下的可执行文件hello。
此时shell通过fork创建一个新的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库和用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。子进程与父进程最大的区别就是有不同的pid。
fork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0.父进程与子进程是并发运行的独立进程。
6.4 Hello的execve过程
execve函数在新创建的子进程的上下文中加载并运行hello程序。execve函数的功能是加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有发生错误时execve才会返回到调用程序。所以,execve调用一次且从不返回。进程的地址空间如图所示。
图6-1 进程的地址空间
加载并运行hello需要以下几个步骤:
-
-
-
- 删除已存在的用户区域
-
-
删除当前进程虚拟地址的用户部分中已存在的区域结构。
-
-
-
- 映射私有区域
-
-
为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
-
-
-
- 映射共享区域
-
-
如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址中的共享区域。
-
-
-
- 设置程序计数器
-
-
设置当前进程上下文中的程序计数器,使之指向代码段的入口点。下一次调度这个进程时,它将从这个入口点开始执行。
6.5 Hello的进程执行
在6.1进程的作用中已经提到过,当前的CPU中并不是只有hello一个程序在运行,这只是一个假象,实际上有很多进程需要执行。首先先了解几个概念:
- 上下文信息
上下文是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
- 进程时间片
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
- 用户模式与内核模式
处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
接下来分析hello程序中的具体执行情况。下图中展示了hello的代码中会主动引起中断的printf函数。
图6-2 会引起中断的printf函数
这段代码中调用了sleep函数,sleep函数的参数的值为2,所以这个sleep函数的作用就是当运行到这一句的时候,程序会产生一个中断,内核会将这个进程挂起,然后运行其它程序,当内核中的计时器到了2秒的时候,会传一个信号给CPU,这时hello进程重新进入待执行进程队列中等待内核调度。
假设hello进程在sleep之前一直在顺序执行。在执行到sleep函数的时候,切换到内核模式,将hello进程挂起,然后切换到用户模式执行其它进程。当到了2秒之后,发生一个中断,切换到内核模式,继续运行之前被挂起的进程。最后切换回用户模式,继续运行hello进程。
6.6 hello的异常与信号处理
1.正常执行
打印8次,回车结束
2.执行中键入回车
程序正常运行
3.键入Ctrl +c
结束进程,回收进程
4.键入Ctrl + z
进程被暂停,然后输入ps查看进程,发现hello确实被暂停,键入jobs查看也一样,该进程并未被收回,只是暂停。
5.输入kill -9 %1 杀死该进程
键入jobs和ps查看,发现进程确实已经被杀死。
6.将暂停进程恢复:使用fg
7. 在进程执行时随便输入,发现输入只是显示在命令行,并未改变进程的执行
6.7本章小结
主要介绍了进程的概念(父进程子进程) 学会了在linux中对于进程的收回,暂停,恢复,以及使用ps ,jobs查看正在执行进程。研究了fork和execve函数的原理以及shellbash,并给出异常执行处理结果。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址(Logical Address)是指由程序产生的与段相关的偏移地址部分,是相对应用程序而言的,如hello.o中代码与数据的相对偏移地址。
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。逻辑地址加上相应段的基地址就生成了一个线性地址,如hello中代码与数据的地址。
有时我们也把逻辑地址称为虚拟地址(Virtual Address)。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。
物理地址(Physical Address)是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址(hello程序运行时代码、数据等对应的可用于直接在内存中寻址的地址)。如果启用了分页机制(当今绝大多数计算机的情况),那么线性地址会使用页目录和页表中的项变换成物理地址;如果没有启用分页机制,那么线性地址就直接成为物理地址了
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel处理器从逻辑地址到线性地址的变换通过段式管理的方式实现。每个程序在系统中都保存着一个段表,段表保存着该程序各段装入主存的状况信息,包括段号或段名、段起点、装入位、段的长度、主存占用区域表、主存可用区域表等,从而方便进行段式管理。
为了运用所有的内存空间,Intel 8086设定了四个段寄存器,专门用来保存段地址:CS(Code Segment):代码段寄存器;DS(Data Segment):数据段寄存器;SS(Stack Segment):堆栈段寄存器;ES(Extra Segment):附加段寄存器。
当一个程序要执行时,就要决定程序代码、数据和堆栈各要用到内存的哪些位置,通过设定段寄存器CS,DS,SS来指向这些起始位置。通常是将DS固定,而根据需要修改CS。所以,程序可以在可寻址空间小于64K的情况下被写成任意大小。所以,程序和其数据组合起来的大小,限制在DS所指的64K内,这就是COM文件不得大于64K的原因。
VM系统通过将虚拟内存分割为称为虚拟页的大小固定的块来处理这个问题,每个虚拟页的大小 (一般为4096字节)。类似地,物理内存被分割成物理页,大小也为P字节(物理页也称为页帧)
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(即虚拟地址VA)到物理地址(PA)之间的转换通过分页机制完成。而分页机制是对虚拟地址内存空间进行分页。
Linux系统有自己的虚拟内存系统,其虚拟内存组织形式如图7-1所示,Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。
图7-1 Linux的虚拟内存系统
系统将每个段分割为被称为虚拟页(VP)的大小固定的块来作为进行数据传输的单元,在linux下每个虚拟页大小为4KB,类似地,物理内存也被分割为物理页(PP/页帧),虚拟内存系统中MMU负责地址翻译,MMU使用存放在物理内存中的被称为页表的数据结构将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
不考虑TLB与多级页表(在7.4节中包含这两者的综合考虑),虚拟地址分为虚拟页号VPN和虚拟页偏移量VPO,根据位数限制分析可以确定VPN和VPO分别占多少位是多少。通过页表基址寄存器PTBR+VPN在页表中获得条目PTE,一条PTE中包含有效位、权限信息、物理页号,如果有效位是0+NULL则代表没有在虚拟内存空间中分配该内存,如果是有效位0+非NULL,则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中,如果有效位是1则代表该内存已经缓存在了物理内存中,可以得到其物理页号PPN,与虚拟页偏移量共同构成物理地址PA。
图7-2 Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
在Intel Core i7环境下研究VA到PA的地址翻译问题。前提如下:
虚拟地址空间48位,物理地址空间52位,页表大小4KB,4级页表。TLB 4路16组相联。CR3指向第一级页表的起始位置(上下文一部分)。
解析前提条件:由一个页表大小4KB,一个PTE条目8B,共512个条目,使用9位二进制索引,一共4个页表共使用36位二进制索引,所以VPN共36位,因为VA 48位,所以VPO 12位;因为TLB共16组,所以TLBI需4位,因为VPN 36位,所以TLBT 32位。
如图7-5所示 ,CPU产生虚拟地址VA,VA传送给MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)向TLB中匹配,如果命中,则得到PPN(40bit)与VPO(12bit)组合成PA(52bit)。
如果TLB中没有命中,MMU向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并且向TLB中添加条目。
如果查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
图7-3 Core i7四级页表下地址翻译情况
7.5 三级Cache支持下的物理内存访问
由于L1、L2、L3各级Cache的原理相同,只做L1 Cache的分析。L1 Cache是8路64组相连高速缓存。块大小64B。因为有64组,所以需要6 bit CI进行组寻址,共有8路,块大小为64B,所以需要6 bit CO表示数据偏移位置,因为VA共52 bit,所以CT共40 bit。
在上一步中已经获得了物理地址VA,使用CI进行组索引,每组8路,对8路的块分别匹配CT(前40位)。如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO(后六位)取出数据返回。
如果没有匹配成功或者匹配成功但是标志位是0,则不命中(miss),向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。如图所示。
图7-4 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
当fork函数被当前进程hello调用时,内核为新进程hello创建各种数据结构,并分配给它一个唯一的PID。为了给这个新的hello创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当着两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数加载并运行hello需要以下几个步骤:
- 删除已存在的用户区域
删除当前进程hello虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域
为新程序的代码、数据、bss和栈区域创建新的私有的、写时复制的区域结构。其中,代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
- 映射共享区域
若hello程序与共享对象或目标(如标准C库libc.so)链接,则将这些对象动态链接到hello程序,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器
最后,execve设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
发生一个缺页异常后,控制会转移到内核的缺页处理程序。判断虚拟地址是否合法,若不合法,则产生一个段错误,然后终止这个进程。
若操作合法,则缺页处理程序从物理内存中确定一个牺牲页,若该牺牲页被修改过,则将它换出到磁盘,换入新的页面并更新页表。当缺页处理程序返回时,CPU 再次执行引起缺页的指令,将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,主存将所请求字返回给处理器。
7.9动态存储分配管理
动态内存分配器维护着一个称为堆的进程的虚拟内存区域。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放可以由应用程序显式执行或内存分配器自身隐式执行。
分配器有两种风格——显式分配器和隐式分配器。C语言中的malloc程序包是一种显式分配器。显式分配器必须在一些相当严格的约束条件下工作:①处理任意请求序列;②立即响应请求;③只使用堆;④对齐块(对齐要求);⑤不修改已分配的块。在以上限制条件下,分配器要最大化吞吐率和内存使用率。常见的放置策略:
首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块。
l 下一次适配:类似于首次适配,但从上一次查找结束的地方开始搜索。
l 最佳适配:选择所有空闲块中适合所需请求大小的最小空闲块。
这里简要介绍一些组织内存块的方法:
(1) 隐式空闲链表:空闲块通过头部中大小字段隐含连接,可添加边界标记提高合并空闲块的速度。
(2) 显式空闲链表:在隐式空闲链表块结构的基础上,在每个空闲块中添加一个pred(前驱)指针和一个succ(后继)指针。
(3) 分离的空闲链表:将块按块大小划分大小类,分配器维护一个空闲链表数组,每个大小类一个空闲链表,减少分配时间同时也提高了内存利用率。C语言中的malloc程序包采用的就是这种方法。
(4) 红黑树等树形结构:按块大小将空闲块组织为树形结构,同样有减少分配时间和提高内存利用率的作用。
7.10本章小结
本章主要介绍了hello 的存储器地址空间、intel 的段式管理、hello 的页式管理, VA 到PA 的变换、物理内存访问,hello进程fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件:所有的I/O设备都被模型化为文件,甚至内核也被映射为文件
设备管理:unix io接口:所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O。
8.2 简述Unix IO接口及其函数
Linux以文件的方式对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);
8.3 printf的实现分析
查看windows系统下的printf函数体:
图8-1 windows系统下的printf函数体
形参列表中的…是可变形参的一种写法,当传递参数的个数不确定时,用这种方式来表示。
va_list的定义:typedef char *va_list,说明它是一个字符指针,其中 (char*)(&fmt) + 4) 即arg表示的是...中的第一个参数。
再进一步查看windows系统下的vsprintf函数体:
图8-2 windows系统下的vsprintf函数体
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
printf函数的功能为接受一个格式化命令,并按指定的匹配的参数格式化输出,故i = vsprintf(buf, fmt, arg)是得到打印出来的字符串长度,其后的write(buf, i)是将buf中的i个元素写到终端。
因此,vsprintf的作用为接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,进而产生格式化输出。
再进一步对write进行追踪:
图8-3 write的情况
这里给几个寄存器传递了参数,然后以一个int INT_VECTOR_SYS_CALL结束。INT_VECTOR_SYS_CALL代表通过系统调用syscall,查看syscall的实现:
图8-4 syscall的情况
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车为止(回车字符也放在缓冲区中)。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了 Linux 的 I/O 设备的基本概念和管理方法,以及Unix I/O 接口及其函数,最后通过对printf函数和getchar函数的底层实现的分析,对其工作过程有了基本了解。
(第8章1分)
结论
hello程序的一生经历了如下过程:
- 预处理
将hello.c中include的所有外部的头文件头文件内容直接插入程序文本中,完成字符串的替换,方便后续处理;
- 编译
通过词法分析和语法分析,将合法指令翻译成等价汇编代码。通过编译过程,编译器将hello.i 翻译成汇编语言文件 hello.s;
- 汇编
将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o 目标文件中;
- 链接
通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello;
- 加载运行
打开Shell,在其中键入 ./hello 1190200208 李旻翀,终端为其fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行;
- 执行指令
在该进程被调度时,CPU为hello其分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器一步一步地更新,CPU不断地取指,顺序执行自己的控制逻辑流;
- 访存
内存管理单元MMU将逻辑地址,一步步映射成物理地址,进而通过三级高速缓存系统访问物理内存/磁盘中的数据;
- 动态申请内存
printf 会调用malloc 向动态内存分配器申请堆中的内存;
- 信号处理
进程时刻等待着信号,如果运行途中键入ctr-c ctr-z 则调用shell 的信号处理函数分别进行停止、挂起等操作,对于其他信号也有相应的操作;
- 终止并被回收
Shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
- 个人感悟:
计算机系统知识点挺多,但是层层相扣,富有逻辑性,仅仅是一个最简单的hello.c文件都可以展现计算机实现的方方面面。CSAPP引领我系统地,全面地学习了计算机地各个机制,设计和原理。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 功能 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2018.4
[2] 常用库函数手册Open Source Guides - Linux Foundation
[2] UTF-8编码规则 UTF-8编码规则(转)_vincent_smm的博客-CSDN博客
[4] ELF文件格式解析ELF文件格式解析_mergerly的博客-CSDN博客_elf格式.
[5] 内存地址转换与分段内存地址转换与分段_drshenlei的博客-CSDN博客
[6] Linux下逻辑地址、线性地址、物理地址详细总结Linux下逻辑地址、线性地址、物理地址详细总结_FreeeLinux的博客-CSDN博客_linux 物理地址 线性地址 逻辑地址
[7] printf 函数实现的深入剖析 [转]printf 函数实现的深入剖析 - Pianistx - 博客园
[8] Linux进程的睡眠和唤醒 Linux进程的睡眠和唤醒_hymansmith的博客-CSDN博客
[8] Linux的jobs命令 Linux的jobs命令_SnailTyan的博客-CSDN博客
(参考文献0分,缺失 -1分)