第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:hello由hello.c文件开始,先经过预处理过程,对宏定义、头文件等进行处理,将文件格式从hello.c变成hello.i。在之后的编译过程中,编译器进行常量表达式的计算等工作,将hello.i文件转变为hello.s文件。随后在汇编阶段,汇编语言被转化为二进制的代码,生成hello.o文件。在最后的链接阶段,进行重定位等工作,得到可执行文件hello。
020:用户在shell中输入./hello命令后,系统调用Fork函数生成一子进程,并在子进程中调用evecve函数。Evecve函数启动加载器loader,将原来的上下文等内容全部丢弃,并新建出task_struct及其目录的数据结构,来映射内存中的私有区域和共享区域,然后设置程序计数器到代码区域的入口点,让程序开始运行。经过一系列的执行过程后,函数执行结束,变为僵尸子进程,等待被父进程回收。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:AMD Ryzen 7 6800H
软件环境:Win11 64位;VMware Workstation Pro:unbantu22.04 LTS
开发及调试工具:Vscode,gcc,gdb,g++
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
1. hello.c
作用:高级语言源程序
2. hello.i
作用:预处理生成的.i文件
3.hello.s
作用:编译生成的.s文件
4.hello.o
作用:汇编生成的.o文件
5. hello.o.asm
作用:hello.o的反汇编格式,用汇编语言的格式来观察可重定位的目标文件
6.hello
作用:链接生成的可执行目标文件
7.hello.asm
作用:hello的反汇编格式,用汇编语言的格式来观察可执行目标文件。
8.hello.o.elf
作用:查看hello.o文件的elf头、节头、程序头等
9.hello.elf
作用:查看hello可执行文件的elf头、节头等
1.4 本章小结
本章节从P2P和020的角度去简要概括了hello的一生,简要列出了开发环境和工具,以及操作过程生成的中间产物等。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理一般是指在成熟源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。处理器根据以字符#开头的命令,修改原始的C程序。一般包含以下几个方面:
1.宏展开:展开所有的宏定义,并删除#define
2.头文件展开:将#include包含的文件插入到该指令的位置
3.条件编译:处理所有的条件预编译指令
4.删除注释
作用:使编译器对代码处理更加方便。
2.2在Ubuntu下预处理的命令
指令:gcc hello.c -E -o hello.i
图2. 1预处理指令及编译结果hello.i |
2.3 Hello的预处理结果解析
图2. 2预处理结果文件
源文件仅30行,但在预处理以后变成3000多行,但只有最后一小部分是源文件内容,其余的内容均为在预处理过程中插入的内容。同时,浏览该文件可以看到,源文件开头的注释部分、#开头的引用头文件部分都被清除,头文件已被插入到对应位置。
2.4 本章小结
本章主要进行了预处理阶段的执行和分析。预处理过程不会对源文件内容产生过多更改,而是会删除注释和插入头文件等。
第3章 编译
3.1 编译的概念与作用
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
概念:编译是指把高级程序设计语言书写的源程序翻译成等价的机器语言格式的目标程序。
作用:初步翻译高级语言,即将其转化为模式化的汇编指令。
3.2 在Ubuntu下编译的命令
(以下格式自行编排,编辑时删除)
应截图,展示编译过程!
命令: gcc hello.i -S -o hello.s
3.3 Hello的编译结果解析
3.3.1 数据存储
(1)main函数中定义了一个未赋值的局部变量int i。局部变量会存在栈中,其生命周期与main函数自身相同。在主函数main的栈中,rsp向下移动了32个字节,其中就有给int i预留的空间。
图3-2 main函数的栈
通过后续for循环对应的循环变量赋值语句可以得知,i存储在rbp向下的4个字节的位置。
图3-3 循环变量赋值语句
- 根据汇编代码,我们可以发现argc和argv分别存储在%edi,%rsi中,并在一开始首先分别保存到了-20(%rbp),-32(%rbp)的位置。
图3-4 命令行参数存储
- 字符串参数均在.text节中存储,并且各自有一个标号。
图3-5 字符串存储
后续访问时,使用的是rip+段偏移量间接寻址
图3-6 间接寻址
3.3.2 赋值操作
在汇编语言下,赋值操作使用movl指令。
图3-7 赋值操作
3.3.3 算术操作
该程序中只有一个简单的算术操作,即循环语句中的i++。在汇编语言下,使用addl实现。
图3-8 算术操作
3.3.4 比较操作
该程序中有两个比较操作,一个是!=,一个是<。
- !=在汇编语言中,使用cmp和je的组合进行实现。cmp命令仅设置标志位,je命令通过标志位进行判断。也即,cmp负责进行比较,而je则通过结果相等或不等进行对应的代码跳转。
图3-9 !=的实现
- <在汇编语言中,使用cmp和jle的组合实现,实现过程与上述!=类似,cmp负责比较,jle根据结果进行跳转。
图3-10 <的实现
3.3.5 循环操作
基于比较操作下的比较和代码跳转进行实现。
循环变量i初始值为0,与8进行比较,共循环8次。
图3-11 循环操作
3.3.6 数组操作
数组的操作一般都是通过首地址加上偏移量得到的,汇编代码中可以观察到这种方式用在了取argv中的字符串的地址。argv数组中的内容存储在了栈中,我们从中取出对应的字符串的地址,并分别放到%rsi和%rdx中,作为printf的第二和第三个参数,最终输出到了屏幕上。
图3-12 数组操作
3.3.7 函数调用
函数调用在汇编中的实现很简单,就是使用call指令。
- printf函数
程序中有两次调用printf函数。
第一次调用时,只有一个参数(字符串),被转化为了puts函数,使用寄存器%rdi传入。
图3-13 第一次printf调用与传参
第二次调用时,共有字符串、argv[0]、argv[1]三个参数,分别通过寄存器%rdi、%rsi、%rdx传入。
图3-14 第二次printf调用与传参
- exit函数
将1作为参数给寄存器%rdi传入
图3-15 exit调用与传参
- sleep函数与atoi函数嵌套
先分析里层的atoi函数,agrv[3]作为参数给寄存器%rdi传入。
图3-16 atoi函数调用与传参
再分析sleep函数。Atoi函数的返回值存入%rax中,再作为参数给寄存器%rdi传入sleep。
图3-17 sleep函数调用与传参
3.3.8 函数返回
函数返回前通常会有这样几个操作:恢复存储被调用者的寄存器的值、恢复旧的帧指针%rbp(不一定有这个操作)、跳转到原来的控制流的地址。最终一般以ret指令结尾。
图3-18 函数返回
3.4 本章小结
本章主要分析了编译结果,详细解释了生成的汇编语言文件hello.s。主要设计到的操作有:数据存储、赋值操作、算术操作、比较操作、循环操作、数组操作、函数调用、函数返回等。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
概念:汇编器将hello.s翻译成机器语言指令,并将结果保存在机器可以读懂的二进制文件即目标文件hello.o中。
作用:将汇编语言翻译成可重定位的二进制目标文件
4.2 在Ubuntu下汇编的命令
指令:as hello.s -o hello.o
图4-1 汇编指令及汇编得到的结果(.o文件)
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
指令:readelf -a hello.o >hello.o.elf
4.3.1 ELF头
图4-2 ELF头
ELF头以一个16字节的序列开始,该序列称为魔数,描述生成了该文件的系统的字的大小和字节顺序。
剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移及其中条目的大小和数量。
4.3.2 节头
图4-3 节头
在ELF头中,我们可以看到一共有13个节,而节头则展示了这些节更为详细的信息。
每一列分别表明了各个节的名称、大小、类型、全体大小、地址、旗标、链接、信息、偏移量和对齐。
4.3.3 重定位节
图4-4 重定位节
‘.rela.text’节是text节的重定位信息,给出了偏移量、信息、寻址类型、符号值、符号名称还有addend的数值。因为还没有进行重定位,所以符号值必然都是0。
‘.rela.eh_frame’节是eh_frame节的重定位信息。
4.3.4 符号表
图4-5 符号表
符号表记录了程序中使用的各个符号的相关信息,各列分别展示了他们的编号、重定位值、大小、类型、全局还是局部、是否可见、是否被定义及名称。同样,因为还没有进行重定位,所以其重定位值均为0。
可以看到,puts、exit、printf等需要从外界调用的函数,此时处于未被定义的状态。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
(1)hello.o.asm(hello.o的反汇编代码文件)中有代码的地址且代码之间有顺序关系,而hello.s代码的没有位置信息和顺序关系。
(2)hello.asm代码跳转使用代码的地址,hello.s则使用段标号
图4-6 代码跳转对比(.asm)
图4-7 代码跳转对比(.s)
- hello.asm的函数调用是跳转到相应的地址,hello.s则使用call加函数名进行调用
图4-8 函数调用对比(.asm)
图4-9 函数调用对比(.s)
4.5 本章小结
本章主要对汇编后的可重定向文件hello.o进行了分析,使用readelf和objdump工具查看hello.o中的信息。并将hello.o与hello.s进行对比,进一步理解二者的内容和关系。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
概念:链接是将各种代码和数据片段收集并组合成为单一文件的过程。
作用:链接使程序模块化编写成为可能,一个大型的程序拆分成多个模块,分别进行编写、编译,最终通过链接得到需要的程序。这样不仅方便编写,后续程序的维护和修改效率也会极大提高。
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 链接指令及编译得到的结果(可执行文件hello)
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
命令:readelf -a hello>hello.elf
hello的elf格式与hello.o的elf格式非常类似
5.3.1 ELF头
图5-2 ELF头
开头是16个字节构成的魔数,后续是ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移及其中条目的大小和数量等信息。
5.3.2 节头
图5-3 节头
每一列分别表明了各个节的名称、大小、类型、全体大小、地址、旗标、链接、信息、偏移量和对齐。
可以看到,因为此时已不需要进行重定位,所以已不存在记录重定位信息的‘.rela.text’和‘.rela.eh_frame’
5.3.3 程序头
图5-4 程序头
每个程序头记录了一个内存段或为准备程序执行而使用的内存的信息。而ELF文件的节与内存段并非一一对应关系,一个内存段可能对应一个或多个节。
程序头只对可执行文件或共享目标文件有意义,对于其它类型的目标文件而言,程序头的信息可以忽略。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
5.4.1 节地址
根据ELF文件中的节头表,可知各个节的起始地址,利用edb的Bookmarks即可对相应地址进行查找,看到各个节对应的汇编代码内容。
图5-5 节头表中’.interp’节的信息
图5-6 利用edb看到的’.interp’节的汇编代码
5.4.2 数据段
根据ELF文件中的程序头部分,可得到数据段的存储地址,而edb的data dump部分的呈现了数据段的内容。
图5-7 程序头中数据段的地址信息
图5-8 edb中的data dump
5.5 链接的重定位过程分析
5.5.1 地址表示不同
在hello.o.asm中,地址使用的是内存中的绝对地址,利用相对偏移量进行表示。而链接之后的hello.asm中。地址使用的则是虚拟内存中的地址。
图5-9 hello.o.asm中使用相对偏移量表示绝对地址
图5-10 hello.asm中使用虚拟内存地址
5.5.2 函数调用表示不同
该程序中所调用的几个函数均不是其本身定义的,均为定义在库文件中的函数。链接之前,系统找不到这些函数的定义,而链接后库文件与程序组合为整体,系统就可以找到它们的定义。
体现在汇编代码中,就是hello.o.asm和hello.asm函数调用表示的不同。hello.o.asm中没有函数定义,调用只能通过跳转地址。hello.asm中有函数的定义,直接调用即可。
图5-11 hello.o.asm中的函数调用
图5-12 hello.asm中的函数调用
5.5.3 重定位过程分析
图5-13 重定位前
图5-14 重定位后
图5-15 ‘.rodata’节的信息
根据节头表可知, ‘.rodata’节的地址为0x402000,偏移量为8。从hello.o.asm中可以看到,重定位使用的是PC相对引用的方式。在图中的lea命令发生时,PC值应当处于下一条命令的位置,即0x401145。通过计算0x402000+0x8-0x401145得到结果为0xec3,即为lea中使用的虚拟内存地址。
5.6 hello的执行流程
(以下格式自行编排,编辑时删除)
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
图5-16 使用gdb运行hello
图5-17 使用gdb进行单步调节查看
大致的运行逻辑如下:
1._dl_start
2._dl_init
3._cax_atexit
4._new_exitfn
5._libc_start_main
6._libc_csu_init
7._main
{
8._printf
9._atoi
10._sleep
}循环8次;
11._getchar
12._exit
13._dl_runtime_resolve_xsave
14._dl_fixup
15._dl_lookup_symbol_x
16.exit
5.7 Hello的动态链接分析
ELF使用的是一种叫做延迟绑定的技术,程序调用由共享库定义的函数时,只有当这个函数在首次被用到时才会被绑定。hello程序通过该技术和PLT和GOT进行动态链接。其中GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
图5-16 ‘.got’和‘.got.plt’节的信息
GOT是地址构成的数组,每个元素为8个字节。和PLT 使用进行动态链接时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的各种信息;GOT[2]是动态链接器在ld-linux.so模块中的入口点;其余元素分别对应一个程序调用的函数,首次使用该函数时,其地址就会被解析。
图5-17 动态链接前GOT的内容
图5-18 动态链接后GOT的内容
5.8 本章小结
本章主要对链接过程进行了介绍,分析链接得到的可执行文件hello与重定位目标文件hello.o的区别。并使用edb对链接使用的虚拟内存及动态链接等过程进行可视化分析。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动,它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
作用:进程为程序提供两个关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统。这两个关键抽象,使得程序能够高效的运行。
6.2 简述壳Shell-bash的作用与处理流程
作用:作为一个命令行解释器,shell壳为用户提供了一种更方便、更安全的与linux内核建立联系的方式。同时,shell壳也能将程序运行的结果输出,直观的反映给用户。
处理流程:
1.读取命令行作为输入
2.通过元字符对输入进行切割,将其分为一个个小的词元(token)。shell的元字符有:space,tab, newline,‘|’, ‘&’, ‘;’, ‘(’, ‘)’, ‘<’, or ‘>’。
3.将词元解析为命令
4.执行各种shell展开(大括号展开,波浪符展开,参数展开,命令替换,算术展开,分词,文件名展开)
5.进行命令所需的重定向
6.执行命令
(1)如果命令中含有/,则会执行对应路径下的程序
(2)如果命令中没有/,则会判断其是否是shell的内置函数,若是则执行对应的操作
(3)如果不是内置函数,则会在PATH路径下进行查找
7.等待命令执行完毕
6.3 Hello的fork进程创建过程
图6-1 进程开始执行的命令
在终端输入图中的命令后,回车将命令传入shell壳。此时,shell壳将命令拆分后,判断词元并非内置函数。然后找到hello程序,将其存入内存。
之后执行fork()函数,创建一个子进程,其拥有和父进程完全相同的虚拟地址空间副本,相对父进程来说是独立的进程。二者有相同的代码段和数据段、堆,共享库和用户栈,区别在于pid的不同。
6.4 Hello的execve过程
fork生成的子进程会调用execve来执行hello程序,该过程共有四步:
1.删除已存在的用户区域
2.映射私有区域,为hello程序的代码、数据、.bss和栈区域创建新区域结构。
3.动态链接hello程序,将其映射到共享区域
4.设置程序计数器PC指向_start地址
6.5 Hello的进程执行
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片
进程上下文:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等构成
程序运行期间,会根据划分的时间片进行进程的转换,而发生进程转换时,必然会发生上下文切换,即保存当前进程的上下文、恢复新进程的上下文、将控制权传递给新进程。
hello进程最初运行在用户模式中,直到其调用sleep函数,请求休眠,休眠时间由用户输入,此时便发生上下文切换。休眠时间结束后,再次发生上下文切换,继续hello进程。
6.6 hello的异常与信号处理
6.6.1 正常运行
图6-2 正常运行
以三个参数运行hello程序,程序会进行8次循环输出,每次输出间隔2秒(即参数3,由学号%4计算得到的秒数)。循环完毕后,再随意输入一个字符后程序结束。
6.6.2 运行过程中按ctrl+z
按ctrl+z,程序停止运行,使用ps命令可以看到,此时程序处于挂起状态。这是因为ctrl+z向shell壳传递了SIGTSTP信号,使程序被挂起。
图6-3 ctrl+z程序停止
图6-4 fg使程序在前台继续运行
之后输入fg,将后台程序转到前台,传递信号SIGCONT,使程序继续在前台运行。
6.6.3 ctrl+c
按ctrl+c,程序停止运行,使用ps命令可以看到,此时hello程序从程序列表中被删除。这是因为ctrl+c向shell壳传递了SIGINT信号,使程序被终止,并让父进程调用waitpid函数等待其子进程结束并回收其子进程。
图6-5 ctrl+c程序停止
6.6.4 jobs
程序运行过程中,分别按ctrl+z和ctrl+c使程序停止运行,然后输入jobs查看系统中目前存在的作业。
图6-6 jobs命令
从此也可以看出二者的不同:ctrl+c是使进程终止,同时从作业列表删除;ctrl+z则是使程序停止运行,并在作业列表中标明。
6.6.5 kill
程序执行过程中,按ctrl-z将程序挂起,并执行ps命令得到其pid。然后执行命令kill +pid,再执行一次ps发现hello进程仍然存在。但再执行fg 命令后提示“继续运行”、“终止”,再执行ps命令发现hello进程已经不复存在。
图6-7 kill命令
说明,kill命令仅会向父进程传递SIGINT信号使子进程终止,而不会使父进程使用waitpid函数等待子进程终止并回收,后者是由fg造成的。
6.6.6 pstree
程序执行过程中,按ctrl-z将程序挂起,然后输入pstree命令,可以看到进程之间的关系,即进程树。
图6-8 进程树
6.6.7 乱按
程序执行过程中乱按键盘,可以看到乱按的内容会立刻输出,但不会对程序自身执行造成影响。
图6-9 乱按
6.7本章小结
本章中主要介绍了hello程序运行过程中的进程管理的各个方面,包括从加载到运行再到运行时各种异常与信号处理的测试。首先说明了进程的概念,然后通过分析fork函数及execve函数,详细讨论了hello程序运行过程中进程的创建及后续执行过程。最后对进程的异常执行与多种信号的处理进行测试。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。一个逻辑地址,是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。
线性地址:跟逻辑地址类似,它也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件页式内存的转换前地址。(在没开启分页功能的情况下线性地址就等于虚拟地址)
虚拟地址:这是对整个内存(不要与机器上插那条对上号)的抽像描述。它是相对于物理内存来讲的,可以直接理解成“不直实的”,“假的”内存。进程使用虚拟内存中的地址,由操作系统协助相关硬件,把它“转换”成真正的物理地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。如果是读,电路根据这个地址每位的值就将相应地址的物理内存中的数据放到数据总线中传输。如果是写,电路根据这个地址每位的值就在相应地址的物理内存中放入数据总线上的内容。物理内存是以字节(8位)为单位编址的。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,[段标识符:段内偏移量]。
段标识符(也叫段选择符)是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号,后面3位包含一些硬件细节。
图7-1 段标识符的结构
通过段标识符中的索引号从GDT或者LDT找到该段的段描述符,段描述符中的base字段是段的起始地址。一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,就得到了基地址。
段起始地址+ 段内偏移量 = 线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
(CPU的页式内存管理单元,负责把一个线性地址,最终翻译为一个物理地址。
线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这样,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。
另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。
每个进程都有自己的页目录,当进程处于运行态的时候,其页目录地址存放在cr3寄存器中。每一个32位的线性地址被划分为三部分,[页目录索引(10位):页表索引(10位):页内偏移(12位)]
依据以下步骤进行转换:
从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
将页的起始地址与线性地址中最后12位相加。
图7-2 线性地址到物理地址变换
7.4 TLB与四级页表支持下的VA到PA的变换
变换步骤如下:
- 首先查看Virtual Address 的高16位VA[63:48]是否为全0,如果全0,使用TTBR0_EL1寄存内放的Level 0 Page Table的基地址; 否则,使用TTBR1_EL1
- 由于是4K的页表,4K页表的大小是这样的计算的: 4KB = 1024 × 8 × 4 = 512 × 64 bit. 就是说4K 的页表要分为512个Entry, 每个Entry的大小为64bit。每个Entry存放的数据,实际是下一个level 的转换表的地址。对于某个VA[47:0], 使用VA[47:39]来索引。这样就可以找到第二级转换表(Level 1 page table)的首地址
- 同样,Level 1 page table 也是4K 共512 个Entry,每个Entry 存放下一个页表的首地址,这个首地址的存放的位置要用VA[38:30]去索引Level 1 page table的Entry 得到. 样就可以找到第三级转换表(Level 2 page table)的首地址
- 同样,Level 2 page table 也是4K 共512 个Entry,每个Entry 存放下一个页表的首地址,这个首地址的存放的位置要用VA[29:21]去索引Level 1 page table的Entry 得到. 样就可以找到第四级转换表(Level 3 page table)的首地址
- Level 3 page table 内存放的就是VA 向 PA转换的Descriptor了, 也是512个entry,每个Entry 存放64bit的数据。通过VA[20:12]来索引使用那个Entry的Descriptor。在这个Descriptor中就可以得到我们想要的物理地址的 PA[47:12]
- 最终的地址转换完成,VA[47:0] 转换为 PA[47:0] = {来自Level 3 转换表的PA[47:12], VA[11:0]}. 就是Descriptor 中给出物理地址的[47:12],而虚拟地址给出物理的值的[11:0]
图7-3 VA到PA的变换
7.5 三级Cache支持下的物理内存访问
PA被分为了CT、CI、CO分别是标志位、组号和偏移量。首先我们根据组号在L1cache中找到对应的组,然后挨个比较标志位,如果标志位对应且有效位为1,则说明发生了hit,然后根据CO偏移量得到我们想要取的数据。如果发生了miss,则依次到L2cache、L3cache、主存中去找。
图7-4 三级Cache下的物理内存访问
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的pid。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork从新进程返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
1.删除当前进程虚拟地址的用户部分中的已存在的区域结构
2.映射私有区域,为hello程序的代码、数据、.bss和栈区域创建新区域结构。所有这些新的区域都是私有的、写时复制的
3.动态链接hello程序,将其映射到用户虚拟地址空间中的共享区域
4.设置程序计数器PC指向_start地址
7.8 缺页故障与缺页中断处理
假设MMU在试图翻译某个虚拟地址A时,出发了一个缺页。这个异常导致控制转移到内核缺页处理程序,处理程序随后就执行下面的步骤:
- 判断虚拟地址A是否合法。异常处理程序搜索区域结构的链表,将A与每一个区域结构的头和尾相比较,判断A是否属于某个区域结构。如果没有匹配到任何结果,说明地址A是不合法的,于是报出段错误。
- 判断进行的内存访问是否合法。如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。
- 如果缺页故障是对合法地址进行合法访问时出现的,就开始处理缺页。内核会选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU,此时,MMU就能正常的翻译A了。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域(堆),分配器将堆视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种风格,即显式分配器和隐式分配器。
显式分配器,要求应用显式地释放任何已分配的块。C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。内核通过调用sbrk函数扩展和收缩堆。
隐式分配器,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块,也叫垃圾收集器。
7.10本章小结
本章主要讨论了hello程序涉及到的存储管理方式。首先介绍了逻辑地址、线性地址、虚拟地址、物理地址四种地址空间,又详细说明了其之间的转换方式,即段式管理、页式管理。此外,本章也说明了hello进程执行过程中fork函数与execve函数的内存映射。同时,也详细阐述了基于MMU的判断缺页异常原因的方式以及对缺页故障的处理机制。最后又对动态存储分配管理机制进行了简单介绍。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
inux 把设备当作一种特殊文件整合到文件系统中,一般通常位于 /dev 目录下。可以使用与普通文件相同的方式来对待这些特殊文件。
特殊文件一般分为两种:
块特殊文件是一个能存储固定大小块信息的设备,它支持以固定大小的块,扇区或群集读取和(可选)写入数据。每个块都有自己的物理地址。所有传输的信息都会以连续的块为单位。块设备的基本特征是每个块都较为对立,能够独立的进行读写。常见的块设备有硬盘、蓝光光盘、USB 盘。与字符设备相比,块设备通常需要较少的引脚。
另一类特殊文件是字符特殊文件。字符设备以字符为单位发送或接收一个字符流,而不考虑任何块结构。字符设备是不可寻址的,也没有任何寻道操作。常见的字符设备有打印机、网络设备、鼠标、以及大多数与磁盘不同的设备。
设备管理:unix io接口
每个设备特殊文件都会和设备驱动相关联。每个驱动程序都通过一个主设备号来标识。如果一个驱动支持多个设备的话,此时会在主设备的后面新加一个次设备号来标识。主设备号和次设备号共同确定了唯一的驱动设备。在计算机系统中,CPU 并不直接和设备打交道,它们中间有一个叫作 设备控制器(Device Control Unit)的组件,例如硬盘有磁盘控制器、USB 有 USB 控制器、显示器有视频控制器等。这些控制器就像代理商一样,它们知道如何应对硬盘、鼠标、键盘、显示器的行为。
8.2 简述Unix IO接口及其函数
1.int open(const char *pathname,int flags,mode_t mode)(该函数有两种形式,另外一种比较常用,int open(const char *pathname,int flags))
作用:打开一个存在的文件或是创建一个新文件
参数:
(1)pathname:打开文件的路径
(2)flags:打开文件的方式
常用的flag:
O_RDONLY(只读)
O_WRONLY(只写)
O_RDWR(读写)
O_CREATE(如果文件不存在就创建)
O_TRUNC(如果文件存在就清空里面的内容)
O_APPEND(以追加的方式打开文件)
(3)mode:如果文件被新建,指定其权限
返回值:成功:return 文件描述符; 失败:return -1
2.int close(int fd)
作用:关闭某个打开的文件
参数: fd:文件描述符
返回值:成功:return 0; 失败:return -1
3.ssize_t read(int fd,void *buf,size_t count)
作用:读取文件fd的内容
参数:
- fd:文件描述符
- buf:存放读到字符的缓冲区
- count: 要读多少字节
返回值:成功:return 成功读到的字符个数; 失败:return -1
4.ssize_t write(int fd,const void*buf,size_t count)
作用:向文件fg写入内容
参数:
- fd:文件描述符
- buf:存放将要写入字符的缓冲区
- count: 要写多少字节
返回值:成功:return 成功写入的字符个数; 失败:return -1
5.off_t lseek(int fd,off_t offset,int whence)
作用:主要用于调整文件位置
参数:
- fd:文件描述符
- offset:新位置相对于基准点的偏移
- whence:基准点、
返回值:成功:return 新文件的偏移量; 失败:return -1
8.3 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;
}
((char*)(&fmt) + 4)表示的是可变参数中的第一个参数的地址。
从vsprintf生成显示信息,vsprintf的作用是格式化。
以下是vsprintf的函数体:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != ‘%’) {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case ‘x’:
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case ‘s’:
break;
default:
break;
}
}
return (p - buf);
}
这个函数返回的是要打印的字符串的长度。
接下来要调用write函数,我们反汇编追踪一下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
我们可以找到INT_VECTOR_SYS_CALL的实现
init_idt_desc(INT_VECTOR_SYS_CALL,DA_386IGate,sys_call,PRIVILEGE_USER),表示要通过系统来调用sys_call这个函数。
ys_call的实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
然后执行字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
最后显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar()是stdio.h中的库函数,它的作用是从stdin流中读入一个字符,也就是说,如果stdin有数据的话不用输入它就可以直接读取了。第一次调用getchar()时,确实需要人工的输入,但是如果你输了多个字符,以后的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;
}
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章简要总结了unix I/O的有关知识,介绍了Linux对I/O设备的管理机制及Unix I/O的接口和其常用函数。又根据源码详细分析了printf和getchar函数。
(第8章1分)
结论
hello程序从诞生到结束会经历很多过程,一个简单的程序的执行过程却囊括了计算机系统课程几乎一个学期所学的内容。
首先,hello程序生成的过程经历五个步骤:
- 将代码从键盘输入,得到hello.c文件
- hello.c经过预处理,处理了#开头的行,包括宏定义、文件包含和条件编译,得到hello.i文件
- hello.i通过编译转化为汇编指令,得到hello.s文件
- hello.s由汇编过程转化为二进制文件,得到hello.o文件
- hello.o在链接过程中进行符号解析和重定位,最终得到可执行文件hello
而后,hello程序在执行过程中,又设计到进程管理、存储管理、I/O管理。
要执行hello程序,需要借助shell壳。通过输入执行命令及命令行参数,使shell壳通过fork创建一个子进程,然后,操作系统使用execve,在当前子进程的上下文中加载并运行hello程序。在程序执行过程中,又会涉及到执行异常与信号处理。
在hello程序执行过程中,需要对内存进行访问。MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。printf会调用malloc向动态内存分配器申请堆中的内存。通过追踪hello执行时内存访问的过程,我们也对计算机系统的存储管理机制进行了简要的阐述。
hello程序的执行结果需要显式的输出到屏幕上,而其执行过程中我们也可以人为从键盘输入一些指令,向系统传递对应的信号,进而影响到hello程序的执行。这些都与I/O操作相关。linux的I/O管理机制,会把所有的外部I/O设备模型化为一个文件,对设备的操作就可以等价为对文件进行读写等操作。
当hello程序彻底执行完毕后,它会以僵尸子进程的形式继续存在,也继续占用着一部分资源。直到其父进程将其回收,它才算是彻底消失。
通过追踪hello程序从头到尾的整个生命流程,我最大的感悟就是计算机系统各个过程间密切的相关性。书本上、课堂上的知识是以割裂的形式学习的,而这样一个对简单程序的完整生命周期的观察,将学到的计算机系统各部分内容在脑中紧密结合了起来了,也让我对计算机的精妙与复杂有了进一步的认识。