大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L020416
班 级 2003004
学 生 张佳鑫
指 导 教 师 史先俊
计算机科学与技术学院
2022年5月
Hello world作为无数程序员的“启蒙老师”,又有多少人真正了解他的完整的生命周期呢?本文就hello源程序如何一步一步从.c文件到一个可执行文件再到在计算机上运行,最后被回收彻底消失的过程,给出了详细的介绍,其中包括预处理,编译,汇编,链接,加载,涉及到很多抽象比如进程,虚拟内存,I/O等概念都将在后续内容中解开hello world的神秘面纱。
关键词:计算机系统;预处理;编译;汇编;链接;加载;虚拟内存;I/O
目 录
第1章 概述
1.1 Hello简介
1.1.1P2P:from Program to Process
对于编写好的hello.c源程序要进行,预处理,编译,汇编,链接等过程,从.c到.i到.s到.o(重定位目标文件)到可执行目标文件,在shell-bash中运行hello,hello的进程就产生了。
1.1.2 020:Zero to Zero
操作系统调用了execve来加载进程,首先映射虚拟内存空间,删除当前的虚拟地址,为hello重新进行一次内存映射,然后进如main函数执行目标程序,执行结束之后,hello进程被回收,hello进程从开始没有被加载到运行后bash被彻底回收,就是020的过程。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2.9GHz;16G RAM
1.2.2 软件环境
Windows10 64位;Vmware 11;Ubuntu 16.04 LTS 64位;
1.2.3 开发工具
gcc,vim,edb,readelf
1.3 中间结果
Hello.c hello源程序
hello.i hello的预处理结果
hello.s hello.i的汇编结果
hello.o hello.s翻译成的可重定位文件
hello 可执行文件
helloo.elf hello.o的ELF格式文件
hello.obj hello的反汇编结果
hello.elf hello的ELF格式文件
。
1.4 本章小结
本章主要介绍了hello.c文件运行的全过程,为执行下面的步骤打好基础,理清思路,同时介绍了实验环境与工具等要求。
第2章 预处理
2.1 预处理的概念与作用
2.2.1概念
所谓预处理,就是在编译之前做的工作,预处理在源代码编译之前对其进行一些文本性质的操作,也就是处理以#号开头的预处理指令如包括#include,宏定制,#define等,生成扩展的c源程序。
常见的预处理指令有这些:
图 2.1 预处理指令
2.2.2作用
这里主要可以分为4类:
- 宏定义:进行了宏替换的过程,定义和替换了由#define指令定义的符号
- #define:将头文件中的内容(源文件之外的文件)插入到源文件中
- 条件编译:#if,#ifdef,#ifndef,#elif,#else和#endif指令可以根据编译器可以测试的条件来将一段文本包含到程序中或排除在程序之外
- 删除掉注释的过程,注释不会带入编译阶段
2.2在Ubuntu下预处理的命令
预处理指令:
gcc -E hello.c -o hello.i
图 2.2 进行预处理
2.3 Hello的预处理结果解析
图 2.3 hello.c
图 2.4 hello.i
这里可以看到较之原先的23行的hello.c源程序,经过预处理已经拓展成了3060行的hello.i,这里可以看到源程序在hello.i的最后有所体现,main函数中的内容没有发生变化,最明显的删除了注释,并且将之前#include中的内容进行了替换。
2.4 本章小结
本章对预处理的概念以及作用进行了分析,并给出了常见的预处理指令,最后在ubuntu的linux系统上进行了测试,对得到的hello.i与hello.c进行了对比,验证了预处理的概念以及作用,为接下来的操作做好铺垫。
第3章 编译
3.1 编译的概念与作用
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
编译程序基于编程语言规则,目标机器的指令集和操作系统遵循的管理,
高级语言转换成机器语言的文本表示,也就是汇编语言,这里的汇编语言相对于机器语言是人类可读的。
3.3.2 作用
生成机器语言二进制文件的文本表示,为不同高级语言的不同编译器提供了相同的输出语言,可以帮助源程序进行优化,提高性能。
3.2 在Ubuntu下编译的命令
编译指令:
gcc -S hello.i -o hello.s
图 3.1 编译运行
3.3 Hello的编译结果解析
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
hello.c的功能:
在不进行查看.s汇编代码之前我们通过.c源文件就能知道程序的目的,该程序需要用户输入4个字符串格式如: Hello 学号 姓名 秒数。如果输入的内容不是4个就要提示输入信息,如果是4个就连续8次,进行输出前三个参数的操作,并且每次休眠一定秒数。(其实输入的参数是3个,因为第一个参数是文件名hello)
3.3.1数据
- 常量
字符串常量:
图 3.2 字符串常量
根据源程序我们知道这里可能用到两个字符串,.s文件分别用LC0和LC1进行表示
- 变量
在介绍变量之前,我们要熟悉通用目的寄存器,这些寄存器一共有16个
图 2.3 16个通用寄存器
可以看到在进入主函数之后main函数首先保护了调用者的寄存器的数值
图 3.4 寄存器使用例子
全局变量:
函数名就是全局变量,如main函数。全局变量可能在该函数定义并引用,也可能在该函数引用但是没被定义。
图 3.5 全局变量定义
atoi函数也是全局变量,但是他是在本程序引用,其他程序定义的。
局部变量:
这里在源程序中可以看到在main体内定义了int i,这里i就是局部变量,局部变量一般在寄存器或者栈中给出空间,这里的i就是在栈中给出的空间并进行了赋初值。
图 3.6 局部变量i
- 立即数
在数据中有一写数据不用变量保存而是直接使用$加数字进行表示,这里就像
图 3.7 栈中元素与立即数的比较
3.3.2赋值
这里最直接的赋值就是对局部变量i的赋值,在图10中给了说明。
3.3.3类型转换
该汇编代码中并没有明显的类型转换,但是在48行使用函数将字符串类型转换成int类型,这里主要是使用的函数。
图 3.8 字符串转整型
3.3.4数值运算
减法与加法
对栈指针进行减法,这里的减法是为了取出栈中元素
图 3.9 栈指针的减法
对栈指针进行加法,这里的减法是为了取出栈中元素
图 3.10 栈指针的加法
c语言中对i++的数值运算操作,每次循环体结束都要进行该操作
图 3.11 局部变量的++运算
3.3.5关系操作与控制转移
一般的关系操作都是为了跳转进行准备,所以这里将比较与跳转放在一起说
- 条件跳转
这里条件跳转和if语句一致,在进行关系比较之后进行跳转
例子如下:
图 3.12 if语句的例子
这里将栈中的内容与4进行比较如果等于的话就跳转到L2如果不等的话就继续往下执行,这里从根本上是条件码的设置与使用。
- 循环中的比较与跳转
在for循环中需要进行关系的比较判断跳出循环的时机
例子如下:
图 3.13 for循环语句的例子
每次for循环一次都进行i++然后和7进行比较作为循环终止条件,如果小于就继续循环,如果大于就跳出了,结束循环。
3.3.6数组
数组一般存放在栈中是一块连续的区域,在该程序中传入的argv是一个字符串数组
例子如下:
图 3.14 数组取值操作
这里对应的操作是循环体内对argv的数组进行取值的操作,可以看到第一个movq的操作将数组的头部给了%rax寄存器,在后面分别有对该头指针addq不同的值但都是8的倍数(因为字符串是以指针存储的,指针是8字节)每次取出对应索引的数组的元素。
3.3.7函数操作
对于一般的函数调用有如下的操作:
- 传递控制:进行过程Q的时候,程序计数器必须设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址
- 传递数据:P必须能够向Q提供一个或多个参数,Q必须能够向P中返回一个值
- 分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间
对应于我们给出的原程序来说最能体现这些步骤的过程就是在条用printf函数时
图 3.15 传入参数调用printf函数
这里分别将三个参数传递给了%rdx,%rsi,%rdi三个寄存器,然后调用printf函数进行输出。
还有atoi函数,sleep函数等函数调用也进行了传入参数等的处理。
3.4 本章小结
本章就hello.s文件进行了汇编语言对应源程序之间关系的分析,知道了c语言的hello.s中各种操作之间的映射关系,理解了编译器是怎么将高级语言转化成汇编语言的简单规则,让我们更加容易看懂汇编代码。
第4章 汇编
4.1 汇编的概念与作用
4.1.1概念
hello.s类的汇编语言机器仍然无法识别,因此需要将编译生成的ASCII汇编语言文件hello.s翻译成一个可重定位目标文件hello.o,目标文件中所存放的也就是与源程序等效的目标的机器语言代码,可被机器识别
4.1.2作用
将汇编代码转换为等效的机器指令,使其在链接后能被机器识别并执行。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
图 4.1 汇编执行
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先使用readelf指令:
readelf -a hello.o > helloo.elf
将所有hello.o的信息全部存入到helloo.elf文件中
图 4.2 可重定位目标文件
图 4.3 ELF Header
先来看第二行的16个字节中的数据的含义。
4个字节是ELF文件的魔数,用来确定文件类型
接下来一个字节表示文件是32位还是64位,其中02代表64位,01代表32位,因此该文件是64位的。
第六个字节表示字节序,01表示小端,02表示大端,因此该文件是小端法。
第七个字节表示版本号,一般都是01。
剩余9个字节没有意义,用0填充。
在下面的信息中也能看到一些关于文件的信息,比如Type:REL说明是可重定位目标文件,除此之外还有另外两种类型,分别是可执行文件和共享文件。
下面给出三种目标文件的区别:
(1)可重定位文件
其中包含有适合于其它目标文件链接来创建一个可执行的或者共享的目标文件的代码和数据。
(2)共享的目标文件
这种文件存放了适合于在两种上下文里链接的代码和数据。第一种是链接程序可把它与其它可重定位文件及共享的目标文件一起处理来创建另一个目标文件;第二种是动态链接程序将它与另一个可执行文件及其它的共享目标文件结合到一起,创建一个进程映象。
(3)可执行文件
它包含了一个可以被操作系统创建一个进程来执行之的文件。汇编程序生成的实际上是第一种类型的目标文件。对于后两种还需要其他的一些处理方能得到,这个就是链接程序的工作了。
在ELF Header中还包含了Section header table的信息,其中第13行起始位置1240个字节,一共13个section header,每个大小64字节都在ELF header中给出。
4.3.2接下里看section headers节头部表
图 4.4 节头部表
这里除了第一个节头没实际意义外,上下每一个节头都代表一个节,节头部表描述了每个节的名称,种类,大小,以及偏移量等每个节的基本信息。
其中根据偏移量和ELF Header的大小我们可以知道.text是紧跟着ELF Header的第一个节,下面依次排序,其中不同节有不同的内容。
.text:已编译程序的机器代码
.rodata:只读数据
.data:已初始化的全局和静态c变量
.bss:没初始化的全局和静态c变量,这里不占用实际空间,只是一个占位符,运行时才会分配变量,初始化为0
.symtab:符号表,存放在程序中定义和引用的函数和全局变量的信息
.rel.text:一个.text节中位置的列表,链接时使用
.rel.data:被模块引用或者定义的所有全局变量的重定位信息,重定位时改动
剩余的不过多展开
4.3.3.rel.text 重定位节
图 4.5 重定位节
汇编器生成一个目标模块时,他并不知道数据和代码最终将放在虚拟内存的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用1,他就会生成一个重定位条目存放在rel.text中,告诉连接器将目标文件合并成可执行文件时如何修改这个引用。
其中offest是需要被修改的引用的节偏移,symbol标识被修改的引用应该指向的符号,type告诉连接器如何修改引用,addend是一个有符号常数,一些类型重定义要使用它对被修改引用的值做出偏移调整。
两种最基本的重定位类型包括R_X86_64_PC32(重定位使用32位PC相对地址的引用)和R_X86_64_32(重定位使用32位绝对地址的引用)。
4.3.4符号表
图 4.6 符号表
符号表就是一个条目数组,每个条目有大致结构如下:
Name:字符串表中的字节偏移,指向符号的的以null结尾的字符串的名字
Value:距离定义目标的节的起始位置的偏移
Size:目标的大小
Type:要么是数据,要么是函数
Bind:全局符号还是局部符号
4.4 Hello.o的结果解析
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
反汇编:
指令:objdump -d -r hello.o
图 4.7 反汇编
图 4.8 反汇编的机器代码与汇编代码
机器语言的构成:机器指令由操作码和操作数构成,而汇编语言是人们比较熟悉的词句直接表述CPU动作形成的语言。每一条汇编语言操作码都可以用机器二进制数据来表示,进而可以将所有的汇编语言(操作码和操作数)和二进制机器语言建立一一映射的关系,因此可以将汇编语言转化为机器语言。
与hello.s对比可以发现,反汇编得到的机器语言与hello.s中的汇编代码两者整体上没有什么大的区分,主要在一下方面有所不同
- 分支转移
由于hello.s中还没有地址的概念,而是使用L0,L1等符号标记跳转的位置,而机器代码使用了直接跳转到指定偏移位置,这里两者有较大区别
图 4.9 机器语言的条件跳转
图 4.10 汇编语言的条件跳转
- 函数调用
在hello.s文件中的函数调用直接call加函数名,而到了机器语言中,使用的是偏移位置进行跳转,这里注意,实际的机器二进制码中并没有给出对应的虚拟地址空间中的地址,因为此时还没有链接,没有重定位所以任然是相对位置偏移
图 4.11机器语言的函数调用
图 4.12汇编语言的函数调用
- 立即数
汇编语言的立即数是10进制,方便人类阅读,而机器语言为了方便机器是2进制,在obj文件中以16进制显示。
4.5 本章小结
对汇编的概念的概念和作用做出了说明,并且详细介绍了可重定位目标文件的elf格式,并且对helle.o进行了反汇编对机器语言和汇编语言进行分析和对比,了解了机器语言与汇编语言的差距。
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.1.1概念
链接(linking)是将各种代码和数据片段收集并合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(run time),也就是在由应用程序来执行
5.1.2作用
链接可以使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。
5.2 在Ubuntu下链接的命令
(以下格式自行编排,编辑时删除)
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
执行指令:
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
这里不仅要对hello.o进行链接,还需要对其他库等进行链接,可见手动进行链接是十分麻烦的,因此gcc驱动程序的存在帮助我们解决了很多问题。
图 5.1 链接指令运行
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
之前在描述可重定位目标文件是说过主要有三种目标文件,这里就是可执行目标文件,依然是ELF格式
首先使用readelf指令:
readelf -a hello > hello.elf
生成一份hello.elf文件便于观察
5.3.1ELF Header
图 5.2 ELF Header
这里的的ELF Header与可重定位相比Type变成了可执行目标文件EXEC,同时有了program headers table,用于进行描述虚拟内存到内存的映射关系,原本的section headers table的条目也变多了。
5.3.2section headers table
图 5.3节头部表
这里与可重定位目标文件的节头部表的格式一致,只是从原本的13个条目拓展到27个条目,不再赘述了
5.3.3program headers table
图 5.4程序头部表
ELF可执行文件被设计成可以很轻易加载到内存,可执行文件的连续的片被映射到连续的内存段。程序头部表就描述了这种映射关系
Offset:目标文件中的偏移;VirtAddr/PhysAddr:虚拟内存空间;Align:对其要求;FileSiz:目标文件中段大小;MemSiz:内存中的段大小;flag:运行访问权限。
拿95行的LOAD距离,这里可以卡看到它的VirtAddr是0x0000000000400000,说明它从此处开始,总共大小为0x00000000000005c0个大小,运行时范文权限是R只读,并且要求是0x1000对其的,这里的对其与虚拟内存的组织方式有关,学习过的同学都知道,虚拟内存到物理内存是片的(4KB)方式传输的,这里要求对其可以提高传输效率根据。
5.3.4重定位节
图 5.6重定位节
由于符号定位已经结束,但是对于共享库的函数的定位目前还不能确定,所以需要重定位节,在动态链接时确定共享库的函数位置。
5.3.5符号表
图 5.7符号表
相比于hello.o,hello有51个符号,多出了一些产生的库函数和必要的启动函数。其余的格式等与可重定位目标文件一致。
图 5.8动态符号表
这里的动态表主要是记录共享库中的符号。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图 5.9第一个LOAD可读段
在5.3的分析中的可以知道,该段是从0x00400000开始,大小为0x5c0,可以看到data Dump到了0x5c0往下就都是0了,这是因为align对齐要求需要到0x1000,所以后面都补0。
再看第二个可读可执行的段
图 5.10可读可执行段
这里标记的开始位置是0x00401000,大小为0x0245,可以看到在data dump到了指定位置后面全部补0,也是因为align对齐要求。
对应到代码我们可以看到这段区域对应的是代码区域,并且到了0x00401245就停止了,与上述分析一致。
图 5.11 代码段
下面就不一一分析了,情况类似,但都能证明program headers table的映射关系是成立的。具体的各个段的分类在下表中给出。
图 5.12 虚拟内存各段的区分
5.5 链接的重定位过程分析
(以下格式自行编排,编辑时删除)
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
使用指令得到反汇编的txt文件
objdump -d -r hello > hello.objdump
5.5.2:hello与hello.o的不同
- 内容不同
可以看到hello.o的.text里的内容相对较少,这是因为此时还没有进行链接,只是单独的hello.o的内容,而hello通过链接对函数进行了代码段进行了补充,导致内容较多
- 地址不同
可以看到hello中的地址都是虚拟地址空间的地址,已经有了确定的地址,而hello.o的代码的地址是从0开始的。
图 5.14hello与hello.o对比
- 函数调用的跳转不同
在hello.o中对函数的调用只是给出了函数的名称然后直接往下一行走,因为此时对这个符号的引用还是不确定的,而到了hello中直接使用函数对应的位置,这里利用的是pc相对取址的方法,把hello.o空出的值进行填写,一确定函数的位置。
5.5.3重定位分析
重定位有以下两个步骤:
- 重定位节和符号定义:链接器将所有相同类型的节合并为同一类型的新的聚合节。然后链接器将运行时的内存地址赋值给新的聚合节,赋值给输入模块定义的每个节,以及赋值给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时的内存地址了。
- 重定位接种的符号引用:在这一步中,链接器修改代码节和数据节中队每个符号的引用,使得他们指向正确的运行时的地址
对于这两个步骤我们需要知道,在重定义节和符号定义之后,原来的各个分散的可重定位目标文件已经合并成一个,并且相应的Program header table已经分配好了各个段的其实位置,也就是说每个符号的位置已经确定。
然后在连接器修改代码节和数据节中队每个符号的引用,具体策略如下。
foreach section s {
foreach relocation entry r {
refptr = s + r.offset; /* ptr to reference to be relocated */
/* Relocate a PC-relative reference */
if (r.type == R_X86_64_PC32) { refaddr = ADDR(s) + r.offset; /* ref’'s run-time address */
*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr);
}
/* Relocate an absolute reference */
if (r.type == R_X86_64_32)
*refptr = (unsigned) (ADDR(r.symbol) + r.addend);
}
在每一个节s以及每一个节相关联的重定位条目r上进行迭代,关于hello.o的条目上面已经给出,为了方便讨论再给出一次。
图 5.15 hello.o可重定位条目
我们以printf函数为例进行分析
图 5.16hello中的printf
图 5.17hello.o下的printf
可见此时的e8后面的字节是空的,而在printf中已经被填写。
在填写重定位位置之前我们是已知printf函数的真实的虚拟地址空间的是0x4010a0,查看hello的section header table可知当前节.text的地址s是0x401125,并且知道offset是0x00005e,那么可以算出refptr = s + r.offset=0x401183;并且根据条目知道addend是-4,方法是间接寻址,所以*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)=0x4010a0-0x4-0x4010f0 =0xffffff19,小端存储与实际相符和
5.6 hello的执行流程
(以下格式自行编排,编辑时删除)
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
进入函数,可以发现首先调用了ld-2.27.so!_dl_start,经过stepover,进入ld-2.27.so!_dl_init,继续点击,程序通过jmp进入Hello!_start。
图 5.13跳转到start
在start中call进入了libc-2.27.so!__libc_start_main然后调用_libc_start_main函数中进入hello!main
在main中顺序调用下列函数
hello!puts@plt
hello!exit@plt
hello!printf@plt
hello!sleep@plt
hello!getchar@plt
图 5.14main中函数调用
最后调用
ld-2.27.so!_dl_runtime_resolve_xsave
ld-2.27.so!_dl_fixup
ld-2.27.so!_dl_lookup_symbol_x
libc-2.27.so!exit
退出程序
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件hello。此时共享库中的代码和数据没有被合并到hello中。加载hello时,动态链接器对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。该过程由动态链接器完成。
链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
在dl_init之前
图 5.16函数之前
之后
图 5.17函数之后
0x404000处发生变化。
5.8 本章小结
本章介绍了链接的概念和作用,对链接后生成的可执行文件hello的elf格式文件进行了分析,并且分析了hello的虚拟地址空间、重定位过程、执行效果的各种处理操作。
第6章 hello进程管理
6.1 进程的概念与作用
异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
其中最重要的是进程提供给用户程序的关键抽象:
1)一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
2)一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。 让我们更深入地看看这些抽象
6.2 简述壳Shell-bash的作用与处理流程
6.2.1作用
shell是一种交互型的应用级程序,他代表用户运行其他程序。Bash是shell的一种变形。Shell执行一系列的读/求值步骤然后停止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,代表用户运行程序。
6.2.2处理步骤
Shell执行一系列的读/求值步骤然后停止
1)读步骤读取来自用户的一个命令行。
2)求值步骤解析命令行,代表用户运行程序。如果是内置命令直接处理,不然调用相应的程序运行。
6.3 Hello的fork进程创建过程
通常情况下父进程调用fork函数创建一个新运行的子进程。
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虛拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以区用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用 fork时,千进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之闻最大的区别在于它们有不同的PID。
其中有4个特点
- 调用一次,返回两次
- 并发执行
- 相同但独立的地址空间,这在后面回再说到,只有进行了写操作才会在内存中有不同片的映射
- 共享文件
在我运行hello程序时输入./hello 120L020416 张佳鑫 1后,shell经过读和求值操作,发现命令不是内置的就会使用fork()创建子进程,在子进程中运行我请求的命令
表 6.1hello运行实例
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序,execve加载filename之后,加载器跳转到_start函数的位置,该函数调用系统启动函数__libc_start_main,该函数初始化执行环境,调用用户层的main函数,这是开始真正执行用户写的程序。
execve在运行时需要以下四个步骤:删除已经存在的用户区域、映射私有区域、映射共享区域、设置程序计数器。这些主要是虚拟内存的内容,会在后面解释。
6.5 Hello的进程执行
(以下格式自行编排,编辑时删除)
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
首先先介绍几个概念
- 逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,正常情况下,我们认为逻辑控制流是平滑的,而关键点在于进程是轮流使用处理器的,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
- 并发流:一个逻辑流的执行在时间上与另一个流重叠,称为并发流。
- 时间片:一个进程执行它的控制流的一部分的每个时间段叫做时间片。
- 上下文概念:上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
- 上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:1.保存当前进程上下文,2.恢复某个先前被抢占的进程保存的上下文,3.将控制传递给这个恢复的新进程
- 用户模式和内核模式:处理器提供一种机制,限制一个应用可以执行的命令以及它可以访问的地址空间的范围。用户程序必须通过程序调用接口间接访问内核代码和数据,用户模式转换到内核模式唯一办法是通过例如中断,故障,系统调用的异常进入内核。
这里我们分析hello进程的执行:在bash调用了execve函数之后,hello就运行在用户模式,在运行时,hello调用sleep函数进行系统调用进入内核,内核将该进程挂起,而不会等他休眠结束,而是开启定时器,选择上下文切换,去运行另外一个进程。当定时器发出中断信号之后,进入内核状态执行中断处理,再重新恢复hello的上下文,运行hello进程。
6.6 hello的异常与信号处理
6.6.1异常的类别
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 返回到下一条指令 |
陷阱和系统调用 | 有意的异常 | 同步 | 返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
在hello中会出现系统调用陷阱类型和中断类型,也就是系统调用,比如sleep()函数,调用函数之后立刻进入内核态进行计算时间,然后切换到其他进程,知道时间到,I/O设备定时器发出中断异常,进入内核态切换为hello进程继续执行。
6.6.2信号类型
图 6.2信号类型
以上类型的信号大多可以出现,把进程从内核模式切换为用户模式时,会检查进程没被堵塞的待处理的信号,收到信号后会触发信号处理程序,进行处理。
6.3.3异常与信号的处理
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
按回车:
图 6.3 运行时按回车
正常换行,没特殊操作
按ctrl+c:
图 6.4按ctrl+c
直接终止程序
按ctrl+z:
图 6.5按ctrl+z
挂起程序,输出一行信息,提示hello进程Stopped
ps:
图 6.6ps指令
给出当前进程情况,的值hello的PID为2950
jobs:
图 6.7 jobs
给出当前的作业只有hello处于Stopped态,没有终止
pstree:
图 6.8pstree
给出进程树,发现其实运行着非常多的进程,也找到了hello进程的位置
fg:
图 6.9 fg
fg指令使得jobs中最后的任务在前台继续运行,因此可以看到hello进程继续运行到结束,在进行ps指令发现hello进程被回收,消失了。
6.7本章小结
本章阐述了进程的概念以及作用,并且介绍了shell的处理机制,分析了fork与execve函数等在hello执行中的作用,最后对hello执行中的异常和信号的处理给出了解释说明。
第7章 hello的存储管理
7.1 hello的存储器地址空间
(以下格式自行编排,编辑时删除)
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.1.1逻辑地址
逻辑地址是指由程序产生的与段相关的偏移地址部分。例如,在进行C语言指针编程中,可以使用&操作读取指针变量的值,这个值就是逻辑地址,是相对于当前进程数据段的地址。一个逻辑地址由两部份组成:段标识符和段内偏移量。
7.1.2线性地址
线性地址是逻辑地址和物理地址之前的地址,段地址+偏移地址 = 线性地址,线性地址本质上是非负整数地址的有序集合:{0,1,2,3...}。
7.1.3虚拟地址
虚拟地址与线性地址很想,他是虚拟的,不是真实存在的,我们可以理解为经过逻辑地址的计算之后得到的就是虚拟地址,虚拟地址的大小有字长决定。
7.1.4物理地址
真是的物理内存对应的地址,在前端总线上传输的地址都是物理地址,也叫绝对地址。
7.1.5结合hello说地址
首先经过对hello可执行程序的反汇编我们得到了的地址是段内偏移如0x00401000等都是段内偏移,这是虚拟地址,再加上代码段的地址就能得到虚拟地址,这里值得一提的是,此时的段基址为0,因为在linux系统中弱化了段的概念,都是在0开始。之后结合虚拟地址,以及mmu的处理才能得到真正在内存上的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.2.1段式管理的基本原理
在段式存储管理中,将程序的地址空间划分为若干个段(segment也叫区域),这样每个进程有一个二维的地址空间。在前面所介绍的动态分区分配方式中,系统为整个进程分配一个连续的内存空间。而在段式存储管理系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。
7.2.2段式管理的数据结构
进程段表:描述组成进程地址空间的各段,可以是指向系统段表中表项的索引。每段有段基址(baseaddress),即段内地址。
段表项:
图 7.1段表项
7.2.3段式管理的地址变换
基本原理为通过段表计算出段基址+段内偏移 = 虚拟地址(线性地址)
图 7.2地址转换
7.3 Hello的线性地址到物理地址的变换-页式管理
7.3.1页式管理基本原理
将程序的逻辑地址空间划分为固定大小的页(page),而物理内存划分为同样大小的页框(page frame)。程序加载时,可将任意一页放人内存中任意一个页框,这些页框不必连续,从而实现了离散分配。
虚拟页面的集合分为三个不相交的子集:
- 非分配的:未分配的块不占用任何磁盘空间,这里我们可以理解,因为前面介绍段式管理时已经说到了,段在虚拟内存中是不连续的,因此很有可能有一部分虚拟内存什么都没有,那么在内核的task_struct任务结构中就不会有这部分段的虚拟内存的分配,自然也就无法在虚拟页面中给出分配,那么在运行时,进行该部分的访问就会引起段错误,但是页表中仍然回给出这部分的条目,显示面是的未分配。
- 缓存:已经在物理内存中的已分配的页
- 未缓存的:未缓存在物理内存中的已分配的页
页式管理方式的优点:
没有外碎片;一个程序不必连续存放;便于改变程序占用空间的大小(主要指随着程序运行,动态生成的数据增多,所要求的地址空间相应增长)。
缺点是:
要求程序全部装入内存,没有足够的内存,程序就不能执行。
7.3.2页式管理的数据结构
主要在内存中维护了页表的数据结构
页表:将虚拟页映射到物理页,每次地址翻译都需要读取页表。
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1TLB
TLB是一个小的,虚拟寻址的缓存,其中每一行都保存着一个由单一PTE组成的块,通常由高度的相联度。
TLB的存在是为了加快从内存或者cache中去出页表条目PTE的时间,做出的一个缓存。
7.4.2多级页表
页表可能由于虚拟地址较大,页表条目较大,页表大小较小的缘故,而非常大,占用内存空间,因此多级页表应运而生。
这里将虚拟地址的VPN进行划分成小段进行对不同级的页表进行查询,进而去除无用的页表项,减少对内存的占用。
7.4.3运行流程
- 得到VA = VPN + VPO(页内偏移)
- 将VPN传入TLB快表中,其中VPN = TLBT(标记)+TLBI(组索引),结合TLBI找到组,查看有效位如果是1,再根据TLBT核对标记,如果核对成功就返回PTE,否则返回到MMU
- 如果没在TLB中得到PTE就需要将VPN作为虚拟页号传到页表中去寻找PTE,在这里由于是4级页表,将VPN分为4份,从一级页表的到二级页表的位置,依次类推到了4几页表就能得到PTE,返回TLB覆盖一个条目,并且更新标记,再从TLB中返回PTE给MMU
- 此时MMU得到PTE加上VPO得到PA再去访问物理内存或者高速缓存,便可以得到数据返回给CPU
7.5 三级Cache支持下的物理内存访问
前面的流程不变,只是再进行内存访问时需要加上cache(S,E,B,m)的访问
其中页表的访问与上述描述一致,唯一不同就是当得到物理地址之后进行的内存访问有区别,大致分为一下三个步骤。
- 组选择:利用组索引s位找到组
- 行匹配:先看有效位是否为1,然后t位标记进行匹配
- 字选择:b位的块偏移可以找到高速缓存块中的偏移位置。
如果不命中就要在下一层去请求块,然后将新块覆盖旧块。
图 7.3 core i7地址翻译及内存访问的概况
7.6 hello进程fork时的内存映射
当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中每个区域结构都标记为私有的写时复制。其内存映射如图:
图 7.4fork函数
7.7 hello进程execve时的内存映射
加载并运行hello,execve函数的内存映射大致4步骤
1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2)映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3)映射共享区域。 hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的。然后再映射到用户虚拟地址空间中的共享区域内。
4)设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
图 7.5execve函数的内存映射
7.8 缺页故障与缺页中断处理
(缺页作为一种异常是故障类型,MMU试图翻译一个虚拟地址A去到内存请求一个PTE时发现该PTE是未缓存的,那么就会引发一个缺页异常,需要在内存中选择一个牺牲页,将新页覆盖旧页,并且修改PTE,然后异常处理程序返回,他重新启动缺页指令,此时就能正常找到页了。
在linux系统中的处理还会做出其他判断,如果程序执行过程中遇到了缺页故障,则内核调用缺页处理程序。处理程序会进行如下步骤:检查虚拟地址是否合法,如果不合法则触发一个段错误,程序终止。然后检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。在两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。
7.9动态存储分配管理
(以下格式自行编排,编辑时删除)
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
7.9.1动态存储分配管理概述
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但不失通用性,假设堆是一个请求二进制零的区域,紧接在未初始化数据区域后开始,向上生长。对每个进程,内核维护一个全局变量brk指向堆顶。分配器将堆视为一组不同大小的块的集合来维护。
7.9.2基本方法与策略
放置已分配块:当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。执行这种搜索的常见策略包括首次适配、下一次适配和最佳适配等。
分割空闲块:一旦分配器找到了匹配的空闲块,需要决定分配这个空闲块中多少空间。可以选择用整个块,但会造成额外的内部碎片;也可以选择将空闲块分割为两部分,第一部分变成已分配块,剩下的变成新的空闲块。
获取额外的堆内存:如果分配器不能为请求块找到空闲块,分配器通过调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插到空闲链表中,然后被请求的块放在这个新的空闲块中。
合并空闲块:分配器释放一个已分配块时,要合并相邻的空闲块。分配器决定何时执行合并,可以选择立即合并或者推迟合并。合并时需要合并当前块和前面以及后面的空闲块。
7.10本章小结
本节首先介绍了不同地址空间的概念与关系,然后介绍了怎么一步一步从逻辑地址到虚拟地址再到物理地址的,主要是经过了段式管理和页式管理两大方式,然后就页式管理我们给出了hello程序整个的一个流程其中包括MMU,TLB,PTE,Cache等概念并给出了完成的实现流程,最后介绍了fork,exceve函数是现实的内存映射的过程,并介绍了动态内存分配管理的机制与策略。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备都被模型化为文件,而所有的输入和输出都被当作相应文件的读和写来完成,这种将设备优雅的映射为文件的方式,允许Linux内核引出一个简单的,低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1简述unix i/o接口
- 打开文件:程序要求内核打开文件,内核返回一个小的非负整数(描述符),用于标识这个文件。程序在只要记录这个描述符便能记录打开文件的所有信息。
- shell在进程的开始为其打开三个文件:标准输入(0)、标准输出(1)和标准错误(2)。
- 改变当前文件的位置:对于每个打开的文件,内核保存着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作显式地设置文件的当前位置为k。
- 读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会出发一个称为EOF的条件,应用程序能检测到这个条件,在文件结尾处并没有明确的EOF符号。
- 关闭文件:内核释放打开文件时创建的数据结构以及占用的内存资源,并将描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2unix IO函数
- intopen(char* filename, int flags, mode_t mode);
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
- intclose(int fd);
fd是需要关闭的文件的描述符(C中表现为指针),close 返回操作结果。
- ssize_t read(int fd, void *buf, size_t n);
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
- ssize_t wirte(int fd,const void *buf,size_t n);
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
图 8.1printf函数实现
首先 arg 获得第二个不定长参数,即输出的时候格式化串对应的值。
然后看vsprinf函数
图 8.2vsprintf函数
这里vsprinf函数利用格式fmt以及上一步获得的args参数,返回的是要打印出来的字符串的长度。
然后是write函数:
图 8.3write函数
这里是给几个寄存器传递了几个参数,然后一个int结束在 printf 中调用系统函数 write(buf,i)将长度为i的buf输出。
最后是syscall函数;
图 8.4syscall函数
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
8.4 getchar的实现分析
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
图 8.6getchar函数
可以看到getchar的底层实现是通过系统函数read实现的。getchar通过read函数从缓冲区中读入一行,但是只返回读入的第一个字符,若读入失败的话,会返回EOF。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中。
8.5本章小结
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数。
结论
- hello.c程序的编写,这里之前我是觉得最重要的,原来是我浅显了,在计算机系统中,这可能是最简单的了
- hello.c预处理,生成hello.i这里主要对以#开头的预处理命令的处理,生成更加完整的文本文件
- hello.i进行编译生成hello.s,这里主要是把高级语言进行抽象,变成更加贴合机器能执行的汇编语言,但实际上还不是机器语言,处在机器语言和高级语言中间
- hello.s进行汇编生成hello.o,这里才是真正生成机器语言,也就是将文本文件转化成了二进制文件,这里虽然是机器语言,但是此时我的一些符号,函数的位置都是不确定的,也就是说我不知道最终在机器中我是什么位置,只有相对位置,都是从0开始的,但是此时已经是机器码了,地址地方先空出来,我生成重定位条目,交给连接器解决
- 链接也分为静态和动态,但是链接主要是两个过程,一是确定符号引用与符号定义,我输入很多的.o文件,他们要和hello.o进行组合,那就要知道我引用的符号是不是否有定义,然后进入重定位,此时连接器拿着.o传上来的重定位条目,结合合并之后的各个段的位置,我就能知道,那些函数的实际位置了,一个一个修改位置就行了,此时hello已经是一个可执行文件了,安静的躺在磁盘中,这里主要,可能有动态链接,在加载和运行时才会完成链接。
- 现在运行hello函数,本质是shell-bash,fork一个子进程,然后execve真正的hello程序,启动加载器。加载器删除子进程现有的虚拟内存段,把hello的各个段进行内存映射,并把栈和堆初始化为0,最后跳转到_start地址,最终调用程序的main函数
- 在此之前,除了一些头部信息,没有任何从磁盘到物理内存的数据辅助。知道cpu引用一个被映射的虚拟页是才会进行复制,在缺页的情况下,操作系统利用页面调度机制,自动将页面从磁盘送到内存
- 很快hello进程就结束了,这时由bash回收hello进程,hello完美的结束了他的绽放,这过程是非常不容易的。
学完计算机系统我才知道,一个hello world是如此的复杂与精妙,以后要更加深入的学习计算机。
附件
Hello.c hello源程序
hello.i hello的预处理结果
hello.s hello.i的汇编结果
hello.o hello.s翻译成的可重定位文件
hello 可执行文件
helloo.elf hello.o的ELF格式文件
hello.obj hello的反汇编结果
hello.elf hello的ELF格式文件
参考文献
- Randal E.Bryant , David R.O Hallaron .《深入理解计算机系统》[M]
- 可执行文件(ELF)格式的理解 - 深海的小鱼儿 - 博客园
- [转]printf 函数实现的深入剖析 - Pianistx - 博客园
- Linux pstree 命令 | 菜鸟教程
- https://blog.csdn.net/qq_52874833/article/details/118273279?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522165096340516782388058822%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fall.%2522%257D&request_id=165096340516782388058822&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~first_rank_ecpm_v1~rank_v31_ecpm-20-118273279.142^v9^pc_search_result_cache,157^v4^control&utm_term=%E5%93%88%E5%B7%A5%E5%A4%A7%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F&spm=1018.2226.3001.4187