前言
摘要
hello是大多数程序员编写的第一个程序。不论在哪种编程语言中,通常都只需要短短几行就可以完成并且运行。但在看似简单运行背后却涉及到很多的东西。既有系统软件的协同处理,也有软件与底层硬件的配合。本文基于Linux平台,通过gcc(cpp/cc1/as/ld)、objdump、gdb、edb等工具对hello程序代码预处理、编译、汇编、链接与反汇编的过程进行分析与比较,并且通过shell及其他Linux内置程序对hello进程运行过程进行了分析。较为深入的研究了一个C语言程序生命周期内包括缓存、虚拟内存、信号在内的各种操作系统与硬件实现的机制。
关键词:操作系统,编译,汇编,虚拟内存
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P过程:
1、程序员编写原始程序hello.c
2、由预处理器(cpp)进行预处理,根据宏命令进行代码的修改,形成hello.i
3、由编译器(cll)将hello.i编译成汇编程序hello.s
4、由汇编器(cs)将hello.s翻译成机器代码并打包成可重定位目标文件hello.o
5、由链接器(ld)将hello.o与其他.o文件链接获得可执行目标文件hello
6、由shell启动程序,调用fork创建子进程,至此完成P2P过程
020过程:
1、由shell通过execve在fork产生的子进程上下文中加载hello
2、映射虚拟函数,进入程序的入口,载入物理内存,执行main函数
3、hello通过IO管理来输入输出
4、程序结束后,shell回收hello进程,内核删除hello的所有痕迹,至此完成020过程
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2.3GHz;16G RAM;512GHD Disk
1.2.2 软件环境
Windows11 64位;VirtualBox;Ubuntu 20.04 LTS 64位;
1.2.3 开发工具
CLion 64位以上;vi/vim/gedit+gcc;edb;
1.3 中间结果
hello.c C代码
hello.i 预处理的文本文件
hello.s 汇编后文本文件
hello.o 可重定位文件
hello 可执行程序
1.4 本章小结
本章主要介绍了hello的p2p,o2o的过程以及实验的环境,软件。列出了为编写本论文,生成的中间结果文件的名字,文件的作用等。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理也称为预编译,它为编译做准备工作,主要进行代码文本的替换工作,用于处理#开头的指令,其中预处理产生编译器的输出。下表是一些常见的预处理指令及其功能(如图2-1)。
经过预处理器处理的源程序与之前的源程序会有所不同,在预处理阶段所进行的工作只是纯粹的替换与展开,没有任何功能。
C语言的预处理主要有三个方面的内容:宏定义;文件包含;条件编译。
预编译程序所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。这个文件的含义同没有经过预处理的源文件是相同的,但内容有所不同。下一步,此输出文件将作为编译程序的输出而被翻译成为机器指令。
2.2在Ubuntu下预处理的命令
预处理的指令为gcc -m64 -no-pie -fno-PIC -E -o hello.i hello.c
预处理结果如下(如图2-2)
2.3 Hello的预处理结果解析
Hello的预处理产生了一个.i文件,将其打开,我们可以看到其中有非常多的代码(如图2-3),共3060行。而我们写的程序源码在文件的最后(如图2-4).这是预处理阶段将宏命令进行展开,替换后的结果。
2.4 本章小结
本章阐述了预处理的概念及作用(宏定义;文件包含;条件编译)。通过对hello.c的预处理生成了hello.i文件并进行了分析。
第3章 编译
3.1 编译的概念与作用
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
编译就是利用编译程序从源语言编写的源程序产生目标程序的过程。这里的编译指的是从C语言源代码生成汇编代码的过程。
该过程为后续产生机器代码做出准备。
3.2 在Ubuntu下编译的命令
Ubuntu下编译的命令为gcc -m64 -no-pie -fno-PIC -S -o hello.s hello.i
编译过程如下(如图3-1)
3.3 Hello的编译结果解析
Hello的编译结果是一个.s的文件,其中包含了源程序代码对应的汇编语言的代码。(如图3-2)
3.3.1整型变量
在程序的初始位置声明了一个整型变量i(如图3-3-1)
这个语句中的整型变量被存放在-4(%rbp)这个位置(如图3-3-2)
3.3.2分支结构与比较操作
在程序源码的13行进行了一个比较操作,以及一个分支跳转的判断。(如图3-3-3)
首先是比较操作,在.s中第24行进行了一次比较,将立即数4与-20(%rbp)比较,并将结果反馈到条件码寄存器。(如图3-3-4)
然后在第25行,根据条件码选择分支的执行,若-20(%rbp)也就是argc不等于4则不跳转,继续执行;若等于,则跳转到.L2处继续执行。在源代码中体现就是若argc!=4则继续执行,反之则执行if语句块后的语句。
3.3.3函数调用与参数传递
在这几行代码中调用了两个函数printf和exit(如图3-3-5),体现在.s的26到29行(如图3-3-6)。
函数在调用前,会将参数放到对应寄存器中,比如%rdi,以及%rsi,%rdx,%rcx,%r8,%r9这几个寄存器或者其对应的低位寄存器中,若函数有多于6个的参数,则会将其他参数放入栈中。
函数的调用使用call命令。
3.3.4字符串常量
调用print函数的时候,放入了一个字符串常量(如图3-3-7)。
在.s程序中对应于第26行,可以看到将$.LC0放入到参数寄存器中(如图3-3-8)。
而.LC0对应于只读数据段中的"\347\224\250\346\263\225: Hello \345\255\246\345\217\267\345\247\223\345\220\215\347\247\222\346\225\260\357\274\201"(如图3-3-9)即程序源码中的"用法: Hello 学号 姓名 秒数!\n"
可以看出对中文字符进行了utf-8编码,中文汉字以‘\347’开头,占三个字符,而全角字符‘!’占用两个字符。
3.3.5赋值语句与算数操作
在源码第17行程序进入一个for循环语句块(如图3-3-10),先来分析其中的初始化表达式。
赋值语句i=0是用命令movl实现的,将立即数$0放入-4(%rbp)也就是前面声明的临时变量i中(如图3-3-11)。
然后是一个比较语句(如图3-3-12),在前面已经介绍过,不再赘述。
接下来是对i进行一个++的算数操作(如图3-3-13)。
通过一个addl指令实现变量i的自加1。
3.3.6循环结构与数组操作
源码从17行到20行对应一个循环结构(如图3-3-14)。这部分对应.s的这些部分(如图3-3-15)。
在.L2中执行i=0;在.L3中执行i<8的比较操作;接下来进入循环体。循环体的最后在.s中第51行完成i++操作。
在循环体中,涉及到了对数组的操作(如图3-3-16)。
在34-40行中取到了argv[1],argv[2]两个数组中的数。argv[1]存放在了-16(%rbp)中地址对应的位置,所以先取到-32(%rbp)的值,再在上边加16,最后根据得到的值以基址+偏移的寻址方式进行寻址,读内存。argv[2],argv[3]同上。
至于对atoi和sleep函数的调用,这里不再赘述(如图3-3-17)。
3.4 本章小结
本章主要介绍了hello.i编译为hello.s的具体操作,阐述了hello.c中的各种操作及数据类型在hello.s中的具体体现,各部分的表达式。主要对编译命令和编译结果进行解析,并加以说明。
第4章 汇编
4.1 汇编的概念与作用
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
在不同的设备中,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。特定的汇编语言和特定的机器语言指令集是一一对应的,不同平台之间不可直接移植。在这里的汇编就是把汇编代码转化成机器代码。
汇编可以将由助记符组成的汇编语言代码转换成对应的机器代码,是机器真正能够执行它。
4.2 在Ubuntu下汇编的命令
Ubuntu下汇编的命令为gcc -m64 -no-pie -fno-PIC -c -o hello.o hello.s
编译过程如下(如图4-1)
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
查看hello.o的ELF格式应使用readelf -a hello.o指令
1、ELF Header
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序(如图4-2)。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,节头部表中条目的大小和数量等。
2、Section Headers
节头表,包含了文件中出现的各个节的语义。记录了每个节的名称、类型、属性(读写权限)、在ELF文件中所占的长度、对齐方式和偏移量。
3、.rela.text
.rela.text是重定位节 ,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。图4-4中 8 条重定位信息分别是对.L0(“用法: Hello 学号 姓名 秒数!\n”)、puts函数、exit函数、.L1(“Hello %s %s\n”)、printf 函数、atoi函数、sleep函数、getchar函数进行重定位声明。重定位条目告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。如图,偏移量Offset是需要被修改的引用的节偏移,符号标识Info被修改引用应该指向的符号。类型Type告知链接器如何修改新的引用,加数Addend是一个有符号常数,一些类型的重定位要用它对被修改引用的值做偏移调整。
3、.symtab
符号表存放在程序中定义和引用的函数和全局变量的信息,其中不包含局部变量的条目。比如,图4-5中10-16行是使用的函数名称。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
对照分析:
1、操作数:
hello.s中的操作数是十进制,而hello.o反汇编代码中的操作数是十六进制。
比如图4-6中c-13行和图4-7中22-24行。
2、分支转移:
跳转语句之后,hello.s中使用段的标号(如图4-8中25行和32行)作为分支后跳转的地址,反汇编代码中用相对main函数起始地址的偏移(如图4-9中17和34行)表示跳转的地址。
3、函数调用:
hello.s中函数调用后直接跟着函数的名字(如图4-10中27和29行),而反汇编代码中call指令之后是函数的相对偏移地址(如图4-11中1e和28行)。
这是因为函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。在机器语言中call后的地址为全0(如图4-11中1e和28行)。在重定位节中有对应的重定位条目(见图4-4),经过链接之后才能确定地址。
4、全局变量:
访问数据时,hello.s依靠段名称和标记来确定访问的内容(如图4-12中26行),反汇编代码中通过重定位的绝对引用确定地址来访问(如图4-13中19行)。
机器语言中待访问的全局变量地址为全0,在重定位节中有对应的重定位条目,链接之后才能确定地址。
4.5 本章小结
本章通过对汇编后产生的 hello.o 的 ELF 格式的分析、对重定位项目的举例分析以及对反汇编文件与 hello.s 的对比,了解了汇编这一过程实现的变化。
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
链接过程将多个可重定位目标文件合并以生成可执行目标文件。
链接的作用有将程序模块化(将程序分为多个文件,分开编写),提高效率(不需要重新编译整个程序,另外,可执行文件和运行时的内存中只需包含所调用函数的代码,而不需要包含整个共享库)。
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
使用命令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 /usr/lib/x86_64-linux-gnu/crtn.o hello.o -lc -z relro -o hello -lpthread来生成可执行文件。
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
使用命令readelf -a hello即可查看hello的ELF格式。
在Section Header中就可以看到各段的基本信息,如图5-2。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
根据程序头表查看各个段的信息
1、 PHDR
2、 代码段
代码段从init开始
3、 数据段
易发现数据段开始的部分存着上述讲过的两串字符串常量
4、 .init节
5、 .text节
6、 rodata节
7、 Stack
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
(图5-12)
- 链接增加新的函数:
在hello中链接加入了在hello.c中用到的库函数,如exit、printf、sleep、getchar等函数。 - 增加一些节:
hello中增加了.init和.plt节,和一些节中定义的函数。 - 函数调用:
hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。 - 地址访问:
hello.o中的相对偏移地址变成了hello中的虚拟内存地址。而hello.o文件中对于某些地址的定位是不明确的,其地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接库中的函数在程序执行的时候才会确定地址,所以编译器无法确定其地址,在汇编代码中也无法像静态库的函数那样体现。
hello程序对动态链接库的引用,基于数据段与代码段相对距离不变这一个事实,因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量。
GNU编译系统采用延迟绑定技术来解决动态库函数模块调用的问题,它将过程地址的绑定推迟到了第一次调用该过程时。
延迟绑定通过全局偏移量表(GOT)和过程链接表(PLT)实现。如果一个目标模块调用定义在共享库中的任何函数,那么就有自己的GOT和PLT。前者是数据段的一部分,后者是代码段的一部分。
进一步介绍,PLT是一个数组,其中每个条目是16字节代码。每个库函数都有自己的PLT条目,PLT[0]是一个特殊的条目,跳转到动态链接器中。从PLT[2]开始的条目调用用户代码调用的函数。
GOT同样是一个数组,每个条目是8字节的地址,和PLT联合使用时,GOT[2]是动态链接在ld-linux.so模块的入口点,其余条目对应于被调用的函数,在运行时被解析。每个条目都有匹配的PLT条目。
从图可看出GOT起始位置为0x404000。
通过edb调试:
在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。如在图
在dl_init调用之后,如图5-16,0x404008和0x404010处的两个8B数据分别发生改变为0x7f54c84ea190和0x7f54c84d5200,如图其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址, GOT[2]指向动态链接器ld-linux.so运行时地址。
5.8 本章小结
本章主要介绍了链接过程,ld经过符号解析和重定位将hello.o与其他库文件一同链接起来,与其相关的有elf头,程序头表。在链接后,hello的反汇编与hello.o的反汇编和编译之后生成的汇编语言文件,三者各不相同,只有链接后的才是可执行文件。
第6章 hello进程管理
6.1 进程的概念与作用
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统。可以说,如果没有进程,体系如此庞大的计算机不可能设计出来。
6.2 简述壳Shell-bash的作用与处理流程
Shell是用户级的应用程序,代表用户控制操作系统中的任务。处理流程如下:
-
读取输入。Shell输入的来源有三种:文件(Shell脚本),调用bash命令时-c选项提供的参数,用户终端。
-
根据引号规则,将输入分为word和operator。word和operator统称为token,token之间用metacharacter分隔(space, tab, newline, |, &, ;, (, ), <, >).
-
将tokens(words和operators)解析为简单命令或复合命令
-
执行各种shell扩展,将扩展的tokens分解为文件名、命令和参数列表。
{ }扩展:{ }内的表达式扩展。
~扩展:〜字符的扩展 Shell参数扩展:将变量扩展为其值。
命令替换:使用命令的输出作为参数。
算术扩展:在Shell扩展中使用算术。
进程替换:一种在命令之间进行读写的方法。
word拆分:扩展结果拆分为单独的参数。
文件名扩展:用于指定文件名匹配模式的简写。 删除引号:从单词中删除引号字符。 -
执行任何必要的重定向,并从参数列表中删除重定向运算符及其操作数。
-
执行命令
执行命令可以分为几步
shell命令行解释器构造argv和envp;
调用fork()函数创建子进程,其地址空间与shell父进程完全相同,包括只读代码段、读写数据段、堆及用户栈等
调用execve()函数在当前进程(新创建的子进程)的上下文中加载并运行hello程序。将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间
调用hello程序的main()函数,hello程序开始在一个进程的上下文中运行。 -
(可选)等待命令完成并收集其exit status。
6.3 Hello的fork进程创建过程
当在终端中输入./hello 学号 姓名时。shell会通过上述流程处理,首先判断出它不是内置命令,所以会认为它是一个当前目录下的可执行文件hello。在加载此进程时shell通过fork创建一个新的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库和用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。子进程与父进程有不同的pid。父进程和子进程是并发运行的独立进程,内核可以任意方式交替执行他们的逻辑控制流中的指令,所以这会导致我们不能简单的凭直觉判断指令执行的顺序。父进程会默认等待子进程执行完之后回收子进程,但是也会有产生僵死进程的情况,父进程可以调用waitpid函数等待其子进程终止或停止
6.4 Hello的execve过程
fork进程后,在子进程中通过判断pid即fork()函数的返回值,判断处于子进程,则会通过execve函数在当前进程的上下文中加载并运行一个新程序。
子进程调用execve函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即hello程序,execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC指向hello程序的代码段开头_start,程序便正式开始执行。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
- 用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
- 时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
- 逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
- 上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
看hello的运行,当运行到sleep,shell会并发的去执行另一个进程,如图所示,在上下文切换的时候,会进入内核模式,且有以下动作:
1) 保存A进程的上下文
2)恢复B进程被保存的上下文
3)将控制传递给这个B进程 ,来完成上下文切换
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
hello执行过程中可能出现四类异常:中断、陷阱、故障和终止。
中断是来自I/O设备的信号,异步发生,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。
会受到的信号太多太多,收到信号如果未用signal函数,则会按默认行为执行。
程序执行时:
按回车:按下回车,程序不会有响应直到程序结束,会多几个空命令行
按Ctrl-C:当按下ctrl-c之后,shell父进程收到SIGINT信号,信号处理函数的逻辑是结束hello,并回收hello进程。(因为Crtl-Z后会使用各种命令,所以调整位置,先测试Ctrl-C)
按Ctrl-Z:
使用ps命令:可发现目前hello也在进程列表里
使用jobs命令
使用pstree命令
使用fg命令:hello会继续放到前台来执行
使用kill命令
6.7本章小结
本章讲述了进程的概念,作用,介绍了shell的功能,处理流程,如何启动一个可执行目标程序,以及在进程执行的时候信号的发送和处理
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
-
逻辑地址(Logical Address)是指由程序产生的与段相关的偏移地址部分。在这里指的是hello.o中的内容。
-
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
-
虚拟地址:CPU启动保护模式后,程序hello运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。
-
物理地址:放在寻址总线上的地址。放在寻址总线上,如果是读,电路根据这个地址每位的值就将相应地址的物理内存中的数据放到数据总线中传输。如果是写,电路根据这个地址每位的值就在相应地址的物理内存中放入数据总线上的内容。物理内存是以字节(8位)为单位编址的。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 Intel 平台下,逻辑地址(logical address)是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address。我们把这个过程称作段式内存管理。
一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
页表就是一个页表条目的数组。虚拟地址空间中的每个页在页表中一个固定的偏移量处都有一个PTE。PTE包含了一个有效位和一个n位字段,有效位表明了该虚拟页当前是否被缓存在DRAM中。因为DRAM是全相联的,所以任意物理页都可包含任意虚拟页。
n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个n-p位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE.将页表条目中的物理页号和VPO串联起来就是相应的物理地址。因为物理和虚拟页面都是P字节的,所以物理页面偏移和虚拟页面偏移是相同的。
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU就必须查阅相应的PTE,这显然造成了巨大的时间开销,为了消除这样的开销,MMU中存在一个关于PTE的小的缓存,称为翻译后备缓冲器(TLB)。
TLB通过虚拟地址VPN部分进行索引,如图7-1,分为索引(TLBI)与标记(TLBT)两个部分。
这样,MMU在读取PTE时会直接通过TLB,如果不命中再从内存中将PTE复制到TLB。
在Intel Core i7环境下虚拟地址空间48位,物理地址空间52位,页表大小4KB,4级页表,如图7-3。TLB 4路16组相联。
解析前提条件:由一个页表大小4KB,一个PTE条目8B,共512个条目,使用9位二进制索引,一共4个页表共使用36位二进制索引,所以VPN共36位,因为VA 48位,所以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(12bit)组合成PA(52bit)。
如果TLB中没有命中,MMU向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并且向TLB中添加条目。
如果查询PTE的时候发现不在物理内存中,则引发缺页故障。
7.5 三级Cache支持下的物理内存访问
Cache有三种:直接映射高速缓存,组相联高速缓存,全相联高速缓存。
-
直接映射高速缓冲:
直接映射高速缓存每个组只有一行,当CPU执行一条读内存字w的指令,它会向L1高速缓存请求这个字。确定是否命中然后抽取的过程分为三步:1)组选择;2)行匹配;3)字抽取。如图7-4,组选择即从w的地址中间抽取出s个索引位,将其解释为一个对应组号的无符号整数,从而找到对应的组;行匹配即对组内的唯一一行进行判断,当有效位为1且标记位与从地址中抽取出的标记位相同则成功匹配,否则就得到不命中。
而字选择即在行匹配的基础上通过地址的后几位得到块偏移,从而在高速缓存块中索引到数据。
-
组相联高速缓存:
每个组内可以多于一个缓存行,总体逻辑类似于直接映射高速缓存,不同之处在于行匹配时每组有更多的行可以尝试匹配,遍历每一行。如果不命中,有空行时也就是冷不命中则直接存储在空行;如果没有空行也就是冲突不命中,则替换已有行,通常有LFU(最不常使用)、LRU(最近最少使用)两者替换策略。 -
全相联高速缓存:
全相联高速缓存只有一个组,且这个组包含所有的高速缓存行。对于全相联高速缓存,因为只有一个组,组选择变的十分简单。地址中不存在索引位,地址只被划分为一个标记位和一个块偏移。行匹配和字选择同组相联高速缓存。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存,创建当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。
7.7 hello进程execve时的内存映射
- 在bash中的进程中执行了如下的execve调用:execve(“hello”,NULL,NULL);
- execve函数在当前进程中加载并运行包含在可执行文件hello中的程序,用hello替代了当前bash中的程序。
- 删除已存在的用户区域。
- 映射私有区域
为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。 - 映射共享区域
hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。 - 设置程序计数器(PC)
- 最后,exceve设置当前进程的上下文中的程序计数器到代码区域的入口点。
7.8 缺页故障与缺页中断处理
页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的:
产生缺页异常和其处理程序的步骤如下:
- 处理器生成一个虚拟地址,并将它传送给MMU
- MMU生成PTE地址,并从高速缓存/主存请求得到它
- 高速缓存/主存向MMU返回PTE
- PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
- 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
- 缺页处理程序页面调入新的页面,并更新内存中的PTE
- 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态储存分配管理使用动态内存分配器(如malloc)来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合。每个块就是一个连续的虚拟内存页,要么是已分配的,要么是空闲的。
已分配的块显式地保留为供应用程序使用。空闲块保持空闲,直到它显式地被应用所分配。
一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。动态内存分配主要有两种基本方法与策略:
带边界标签的隐式空闲链表分配器管理
带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、(可能的)额外填充以及一个字的尾部组成。
隐式空闲链表:空闲块通过头部的大小字段隐含地连接着。分配器遍历堆中所有的块,间接地遍历整个空闲块的集合。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配和最佳适配。分配器在面对释放一个已分配块时,可以合并相邻的空闲块,其中一种简单的方式,是利用隐式空闲链表的边界标记来进行合并。
显式空间链表管理
显式空闲链表是将堆的空闲块组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。进行内存管理。在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章讨论了存储器地址空间,段式管理、页式管理,TLB与四级页表支持下的VA到PA的变换,三级Cache支持下的物理内存访问,hello进程fork时和execve时的内存映射,缺页故障与缺页中断处理和动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
Linux将文件所有的I/O设备都模型化为文件,甚至内核也被映射为文件。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。Linux就是基于Unix I/O实现对设备的管理。
8.2 简述Unix IO接口及其函数
1、open()用来打开文件,打开一个已经存在的文件或者创建一个新文件
2、close(),关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
3、写入文件write(),从内存复制字节到当前文件位置,write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
4、读取文件read(),从当前文件位置复制字节到内存,函数从描述符为fd 的当前文件位置复制最多n个字节到内存位置buf。返回值一1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
5、改变文件位置lseek()。
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
首先,(char*)(&fmt) + 4) 表示的是…中的第一个参数。
变量i从vsprintf函数取到将要输出字符串的长度,作为函数返回值,并把字符串放到buf中。
write把buf中的i个元素的值写到终端,write即为前文提到的unix的io接口对应的函数。
8.4 getchar的实现分析
getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车为止(回车字符也放在缓冲区中)。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要阐述了Linux的IO设备管理办法以及IO接口实现与相应的函数实现。分析了getchar()和printf()函数的实现。
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
结论
hello是大多数程序员编写的第一个程序。不论在哪种编程语言中,通常都只需要短短几行就可以完成并且运行。但在看似简单运行背后却涉及到很多的东西。既有系统软件的协同处理,也有软件与底层硬件的配合。经过以上的学习和实践,我们大概可以将hello程序的一生分以下的步骤:
1、编程:创建并编写源程序hello.c。
2、预处理:将hello.c进行预处理生成hello.i,我们可以在hello.i中发现宏命令被处理产生的变化。
3、编译:hello.i编译生成hello.s,hello.i中的内容进一步处理,被编译为接近机器代码的汇编代码。
4、汇编:hello.s汇编生成hello.o,hello.s中的汇编代码被逐条转换为机器代码,可以让机器直接执行。
5、链接:hello.o与其他.o文件及库文件链接生成了可执行目标文件hello
6、在shell输入命令行,运行hello
7、shell中调用fork函数创建子进程,并调用execve函数加载并运行hello
8、系统在hello的时间片里执行hello发出的指令,hello访问内存中的数据。hello有自己的虚拟地址空间,当有访存操作时,MMU 把虚拟地址翻译成物理地址,通过三级 cache 访问内存。
9、处理运行中收到的信号。程序运行时可能会有许多异常,比如 ctrl+z/c,hello 中的 sleep 函数,会触发上下文切换,hello 会在用户态和内核态切换。printf、getchar 函数会调用系统I/O从缓冲区读取字符进行处理。
10、hello会一直运行,直到收到SIGINT信号或者正常退出,进程终止。hello被shell父进程回收子进程,进程消失。
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.c C代码
hello.i 预处理的文本文件
hello.s 汇编后文本文件
hello.o 可重定位文件
hello 可执行程序
参考文献
[1] 预处理与编译(https://www.cnblogs.com/noticeable/p/9310798.html)
[2] 程序的链接(一):链接的概述(https://www.jianshu.com/p/b7e44f749211)
[3] Computer System: A Programmer’s Perspective, Third Edition
[4] Linux文件IO操作函数概述
(https://www.cnblogs.com/wangkeqin/p/9226825.html)
[5] printf函数实现的深入剖析(https://www.cnblogs.com/pianist/p/3315801.html)
[6] 百度百科-逻辑地址(https://baike.baidu.com/item/逻辑地址/3283849?fr=aladdin)
[7] 百度百科-逻辑地址(https://baike.baidu.com/item/线性地址)
[8] 百度百科-逻辑地址(https://baike.baidu.com/item/虚拟地址)
[9] Linux下的文件I/O编程(https://www.linuxprobe.com/linux-file-i-o.html)