大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L020416
班 级 2003004
学 生 张佳鑫
指 导 教 师 史先俊
计算机科学与技术学院
2022年5月
摘 要
Hello world作为无数程序员的“启蒙老师”,又有多少人真正了解他的完整的生命周期呢?本文就hello源程序如何一步一步从.c文件到一个可执行文件再到在计算机上运行,最后被回收彻底消失的过程,给出了详细的介绍,其中包括预处理,编译,汇编,链接,加载,涉及到很多抽象比如进程,虚拟内存,I/O等概念都将在后续内容中解开hello world的神秘面纱。
关键词:计算机系统;预处理;编译;汇编;链接;加载;虚拟内存;I/O
目 录
第1章 概述
1.1 Hello简介
1.2 环境与工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1 预处理的概念与作用
2.2.1概念
2.2.2作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.1.1概念
3.3.2 作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.3.1数据
3.3.2赋值
3.3.3类型转换
3.3.4数值运算
3.3.5关系操作与控制转移
3.3.6数组
3.3.7函数操作
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.1.1概念
4.1.2作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.3.1首先分析ELF Header的信息
4.3.2接下里看section headers节头部表
4.3.3.rel.text 重定位节
4.3.4符号表
4.4 Hello.o的结果解析
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.1.1概念
5.1.2作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.3.1ELF Header
5.3.2section headers table
5.3.3program headers table
5.3.4重定位节
5.3.5符号表
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.5.2:hello与hello.o的不同
5.5.3重定位分析
5.6 hello的执行流程
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
6.2.1作用
6.2.2处理步骤
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.6 hello的异常与信号处理
6.6.1异常的类别
6.6.2信号类型
6.3.3异常与信号的处理
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
7.1.2线性地址
7.1.3虚拟地址
7.1.4物理地址
7.1.5结合hello说地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.2.1段式管理的基本原理
7.2.2段式管理的数据结构
7.2.3段式管理的地址变换
7.3 Hello的线性地址到物理地址的变换-页式管理
7.3.1页式管理基本原理
7.3.2页式管理的数据结构
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1TLB
7.4.2多级页表
7.4.3运行流程
7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
7.9.1动态存储分配管理概述
7.9.2基本方法与策略
7.10本章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
附件
参考文献
对于编写好的hello.c源程序要进行,预处理,编译,汇编,链接等过程,从.c到.i到.s到.o(重定位目标文件)到可执行目标文件,在shell-bash中运行hello,hello的进程就产生了。
操作系统调用了execve来加载进程,首先映射虚拟内存空间,删除当前的虚拟地址,为hello重新进行一次内存映射,然后进如main函数执行目标程序,执行结束之后,hello进程被回收,hello进程从开始没有被加载到运行后bash被彻底回收,就是020的过程。
gcc,vim,edb,readelf
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格式文件
。
本章主要介绍了hello.c文件运行的全过程,为执行下面的步骤打好基础,理清思路,同时介绍了实验环境与工具等要求。
所谓预处理,就是在编译之前做的工作,预处理在源代码编译之前对其进行一些文本性质的操作,也就是处理以#号开头的预处理指令如包括#include,宏定制,#define等,生成扩展的c源程序。
常见的预处理指令有这些:
图 2.1 预处理指令
这里主要可以分为4类:
预处理指令:
gcc -E hello.c -o hello.i
图 2.2 进行预处理
图 2.3 hello.c
图 2.4 hello.i
这里可以看到较之原先的23行的hello.c源程序,经过预处理已经拓展成了3060行的hello.i,这里可以看到源程序在hello.i的最后有所体现,main函数中的内容没有发生变化,最明显的删除了注释,并且将之前#include中的内容进行了替换。
本章对预处理的概念以及作用进行了分析,并给出了常见的预处理指令,最后在ubuntu的linux系统上进行了测试,对得到的hello.i与hello.c进行了对比,验证了预处理的概念以及作用,为接下来的操作做好铺垫。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.1.1概念
编译程序基于编程语言规则,目标机器的指令集和操作系统遵循的管理,
高级语言转换成机器语言的文本表示,也就是汇编语言,这里的汇编语言相对于机器语言是人类可读的。
生成机器语言二进制文件的文本表示,为不同高级语言的不同编译器提供了相同的输出语言,可以帮助源程序进行优化,提高性能。
编译指令:
gcc -S hello.i -o hello.s
图 3.1 编译运行
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
hello.c的功能:
在不进行查看.s汇编代码之前我们通过.c源文件就能知道程序的目的,该程序需要用户输入4个字符串格式如: Hello 学号 姓名 秒数。如果输入的内容不是4个就要提示输入信息,如果是4个就连续8次,进行输出前三个参数的操作,并且每次休眠一定秒数。(其实输入的参数是3个,因为第一个参数是文件名hello)
字符串常量:
图 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 栈中元素与立即数的比较
这里最直接的赋值就是对局部变量i的赋值,在图10中给了说明。
该汇编代码中并没有明显的类型转换,但是在48行使用函数将字符串类型转换成int类型,这里主要是使用的函数。
图 3.8 字符串转整型
减法与加法
对栈指针进行减法,这里的减法是为了取出栈中元素
图 3.9 栈指针的减法
对栈指针进行加法,这里的减法是为了取出栈中元素
图 3.10 栈指针的加法
c语言中对i++的数值运算操作,每次循环体结束都要进行该操作
图 3.11 局部变量的++运算
一般的关系操作都是为了跳转进行准备,所以这里将比较与跳转放在一起说
这里条件跳转和if语句一致,在进行关系比较之后进行跳转
例子如下:
图 3.12 if语句的例子
这里将栈中的内容与4进行比较如果等于的话就跳转到L2如果不等的话就继续往下执行,这里从根本上是条件码的设置与使用。
在for循环中需要进行关系的比较判断跳出循环的时机
例子如下:
图 3.13 for循环语句的例子
每次for循环一次都进行i++然后和7进行比较作为循环终止条件,如果小于就继续循环,如果大于就跳出了,结束循环。
数组一般存放在栈中是一块连续的区域,在该程序中传入的argv是一个字符串数组
例子如下:
图 3.14 数组取值操作
这里对应的操作是循环体内对argv的数组进行取值的操作,可以看到第一个movq的操作将数组的头部给了%rax寄存器,在后面分别有对该头指针addq不同的值但都是8的倍数(因为字符串是以指针存储的,指针是8字节)每次取出对应索引的数组的元素。
对于一般的函数调用有如下的操作:
对应于我们给出的原程序来说最能体现这些步骤的过程就是在条用printf函数时
图 3.15 传入参数调用printf函数
这里分别将三个参数传递给了%rdx,%rsi,%rdi三个寄存器,然后调用printf函数进行输出。
还有atoi函数,sleep函数等函数调用也进行了传入参数等的处理。
本章就hello.s文件进行了汇编语言对应源程序之间关系的分析,知道了c语言的hello.s中各种操作之间的映射关系,理解了编译器是怎么将高级语言转化成汇编语言的简单规则,让我们更加容易看懂汇编代码。
第4章 汇编
hello.s类的汇编语言机器仍然无法识别,因此需要将编译生成的ASCII汇编语言文件hello.s翻译成一个可重定位目标文件hello.o,目标文件中所存放的也就是与源程序等效的目标的机器语言代码,可被机器识别
将汇编代码转换为等效的机器指令,使其在链接后能被机器识别并执行。
gcc -c hello.s -o hello.o
图 4.1 汇编执行
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先使用readelf指令:
readelf -a hello.o > helloo.elf
将所有hello.o的信息全部存入到helloo.elf文件中
图 4.2 可重定位目标文件
4.3.1首先分析ELF Header的信息
图 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.4 节头部表
这里除了第一个节头没实际意义外,上下每一个节头都代表一个节,节头部表描述了每个节的名称,种类,大小,以及偏移量等每个节的基本信息。
其中根据偏移量和ELF Header的大小我们可以知道.text是紧跟着ELF Header的第一个节,下面依次排序,其中不同节有不同的内容。
.text:已编译程序的机器代码
.rodata:只读数据
.data:已初始化的全局和静态c变量
.bss:没初始化的全局和静态c变量,这里不占用实际空间,只是一个占位符,运行时才会分配变量,初始化为0
.symtab:符号表,存放在程序中定义和引用的函数和全局变量的信息
.rel.text:一个.text节中位置的列表,链接时使用
.rel.data:被模块引用或者定义的所有全局变量的重定位信息,重定位时改动
剩余的不过多展开
图 4.5 重定位节
汇编器生成一个目标模块时,他并不知道数据和代码最终将放在虚拟内存的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用1,他就会生成一个重定位条目存放在rel.text中,告诉连接器将目标文件合并成可执行文件时如何修改这个引用。
其中offest是需要被修改的引用的节偏移,symbol标识被修改的引用应该指向的符号,type告诉连接器如何修改引用,addend是一个有符号常数,一些类型重定义要使用它对被修改引用的值做出偏移调整。
两种最基本的重定位类型包括R_X86_64_PC32(重定位使用32位PC相对地址的引用)和R_X86_64_32(重定位使用32位绝对地址的引用)。
图 4.6 符号表
符号表就是一个条目数组,每个条目有大致结构如下:
Name:字符串表中的字节偏移,指向符号的的以null结尾的字符串的名字
Value:距离定义目标的节的起始位置的偏移
Size:目标的大小
Type:要么是数据,要么是函数
Bind:全局符号还是局部符号
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
反汇编:
指令: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进制显示。
对汇编的概念的概念和作用做出了说明,并且详细介绍了可重定位目标文件的elf格式,并且对helle.o进行了反汇编对机器语言和汇编语言进行分析和对比,了解了机器语言与汇编语言的差距。
注意:这儿的链接是指从 hello.o 到hello生成过程。
链接(linking)是将各种代码和数据片段收集并合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(run time),也就是在由应用程序来执行
链接可以使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。
(以下格式自行编排,编辑时删除)
使用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 链接指令运行
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
之前在描述可重定位目标文件是说过主要有三种目标文件,这里就是可执行目标文件,依然是ELF格式
首先使用readelf指令:
readelf -a hello > hello.elf
生成一份hello.elf文件便于观察
图 5.2 ELF Header
这里的的ELF Header与可重定位相比Type变成了可执行目标文件EXEC,同时有了program headers table,用于进行描述虚拟内存到内存的映射关系,原本的section headers table的条目也变多了。
图 5.3节头部表
这里与可重定位目标文件的节头部表的格式一致,只是从原本的13个条目拓展到27个条目,不再赘述了
图 5.4程序头部表
ELF可执行文件被设计成可以很轻易加载到内存,可执行文件的连续的片被映射到连续的内存段。程序头部表就描述了这种映射关系
Offset:目标文件中的偏移;VirtAddr/PhysAddr:虚拟内存空间;Align:对其要求;FileSiz:目标文件中段大小;MemSiz:内存中的段大小;flag:运行访问权限。
拿95行的LOAD距离,这里可以卡看到它的VirtAddr是0x0000000000400000,说明它从此处开始,总共大小为0x00000000000005c0个大小,运行时范文权限是R只读,并且要求是0x1000对其的,这里的对其与虚拟内存的组织方式有关,学习过的同学都知道,虚拟内存到物理内存是片的(4KB)方式传输的,这里要求对其可以提高传输效率根据。
图 5.6重定位节
由于符号定位已经结束,但是对于共享库的函数的定位目前还不能确定,所以需要重定位节,在动态链接时确定共享库的函数位置。
图 5.7符号表
相比于hello.o,hello有51个符号,多出了一些产生的库函数和必要的启动函数。其余的格式等与可重定位目标文件一致。
图 5.8动态符号表
这里的动态表主要是记录共享库中的符号。
使用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 虚拟内存各段的区分
(以下格式自行编排,编辑时删除)
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
使用指令得到反汇编的txt文件
objdump -d -r hello > hello.objdump
可以看到hello.o的.text里的内容相对较少,这是因为此时还没有进行链接,只是单独的hello.o的内容,而hello通过链接对函数进行了代码段进行了补充,导致内容较多
可以看到hello中的地址都是虚拟地址空间的地址,已经有了确定的地址,而hello.o的代码的地址是从0开始的。
图 5.14hello与hello.o对比
在hello.o中对函数的调用只是给出了函数的名称然后直接往下一行走,因为此时对这个符号的引用还是不确定的,而到了hello中直接使用函数对应的位置,这里利用的是pc相对取址的方法,把hello.o空出的值进行填写,一确定函数的位置。
重定位有以下两个步骤:
对于这两个步骤我们需要知道,在重定义节和符号定义之后,原来的各个分散的可重定位目标文件已经合并成一个,并且相应的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,小端存储与实际相符和
(以下格式自行编排,编辑时删除)
使用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
退出程序
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件hello。此时共享库中的代码和数据没有被合并到hello中。加载hello时,动态链接器对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。该过程由动态链接器完成。
链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
在dl_init之前
图 5.16函数之前
之后
图 5.17函数之后
0x404000处发生变化。
本章介绍了链接的概念和作用,对链接后生成的可执行文件hello的elf格式文件进行了分析,并且分析了hello的虚拟地址空间、重定位过程、执行效果的各种处理操作。
异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
其中最重要的是进程提供给用户程序的关键抽象:
1)一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
2)一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。 让我们更深入地看看这些抽象
shell是一种交互型的应用级程序,他代表用户运行其他程序。Bash是shell的一种变形。Shell执行一系列的读/求值步骤然后停止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,代表用户运行程序。
Shell执行一系列的读/求值步骤然后停止
1)读步骤读取来自用户的一个命令行。
2)求值步骤解析命令行,代表用户运行程序。如果是内置命令直接处理,不然调用相应的程序运行。
通常情况下父进程调用fork函数创建一个新运行的子进程。
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虛拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以区用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用 fork时,千进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之闻最大的区别在于它们有不同的PID。
其中有4个特点
在我运行hello程序时输入./hello 120L020416 张佳鑫 1后,shell经过读和求值操作,发现命令不是内置的就会使用fork()创建子进程,在子进程中运行我请求的命令
表 6.1hello运行实例
execve函数在当前进程的上下文中加载并运行一个新程序,execve加载filename之后,加载器跳转到_start函数的位置,该函数调用系统启动函数__libc_start_main,该函数初始化执行环境,调用用户层的main函数,这是开始真正执行用户写的程序。
execve在运行时需要以下四个步骤:删除已经存在的用户区域、映射私有区域、映射共享区域、设置程序计数器。这些主要是虚拟内存的内容,会在后面解释。
(以下格式自行编排,编辑时删除)
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
首先先介绍几个概念
这里我们分析hello进程的执行:在bash调用了execve函数之后,hello就运行在用户模式,在运行时,hello调用sleep函数进行系统调用进入内核,内核将该进程挂起,而不会等他休眠结束,而是开启定时器,选择上下文切换,去运行另外一个进程。当定时器发出中断信号之后,进入内核状态执行中断处理,再重新恢复hello的上下文,运行hello进程。
类别 |
原因 |
异步/同步 |
返回行为 |
中断 |
来自I/O设备的信号 |
异步 |
返回到下一条指令 |
陷阱和系统调用 |
有意的异常 |
同步 |
返回到下一条指令 |
故障 |
潜在可恢复的错误 |
同步 |
可能返回当前指令 |
终止 |
不可恢复的错误 |
同步 |
不会返回 |
在hello中会出现系统调用陷阱类型和中断类型,也就是系统调用,比如sleep()函数,调用函数之后立刻进入内核态进行计算时间,然后切换到其他进程,知道时间到,I/O设备定时器发出中断异常,进入内核态切换为hello进程继续执行。
图 6.2信号类型
以上类型的信号大多可以出现,把进程从内核模式切换为用户模式时,会检查进程没被堵塞的待处理的信号,收到信号后会触发信号处理程序,进行处理。
程序运行过程中可以按键盘,如不停乱按,包括回车,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进程被回收,消失了。
本章阐述了进程的概念以及作用,并且介绍了shell的处理机制,分析了fork与execve函数等在hello执行中的作用,最后对hello执行中的异常和信号的处理给出了解释说明。
(以下格式自行编排,编辑时删除)
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址是指由程序产生的与段相关的偏移地址部分。例如,在进行C语言指针编程中,可以使用&操作读取指针变量的值,这个值就是逻辑地址,是相对于当前进程数据段的地址。一个逻辑地址由两部份组成:段标识符和段内偏移量。
线性地址是逻辑地址和物理地址之前的地址,段地址+偏移地址 = 线性地址,线性地址本质上是非负整数地址的有序集合:{0,1,2,3...}。
虚拟地址与线性地址很想,他是虚拟的,不是真实存在的,我们可以理解为经过逻辑地址的计算之后得到的就是虚拟地址,虚拟地址的大小有字长决定。
真是的物理内存对应的地址,在前端总线上传输的地址都是物理地址,也叫绝对地址。
首先经过对hello可执行程序的反汇编我们得到了的地址是段内偏移如0x00401000等都是段内偏移,这是虚拟地址,再加上代码段的地址就能得到虚拟地址,这里值得一提的是,此时的段基址为0,因为在linux系统中弱化了段的概念,都是在0开始。之后结合虚拟地址,以及mmu的处理才能得到真正在内存上的物理地址。
在段式存储管理中,将程序的地址空间划分为若干个段(segment也叫区域),这样每个进程有一个二维的地址空间。在前面所介绍的动态分区分配方式中,系统为整个进程分配一个连续的内存空间。而在段式存储管理系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。
进程段表:描述组成进程地址空间的各段,可以是指向系统段表中表项的索引。每段有段基址(baseaddress),即段内地址。
段表项:
图 7.1段表项
基本原理为通过段表计算出段基址+段内偏移 = 虚拟地址(线性地址)
图 7.2地址转换
将程序的逻辑地址空间划分为固定大小的页(page),而物理内存划分为同样大小的页框(page frame)。程序加载时,可将任意一页放人内存中任意一个页框,这些页框不必连续,从而实现了离散分配。
虚拟页面的集合分为三个不相交的子集:
页式管理方式的优点:
没有外碎片;一个程序不必连续存放;便于改变程序占用空间的大小(主要指随着程序运行,动态生成的数据增多,所要求的地址空间相应增长)。
缺点是:
要求程序全部装入内存,没有足够的内存,程序就不能执行。
主要在内存中维护了页表的数据结构
页表:将虚拟页映射到物理页,每次地址翻译都需要读取页表。
TLB是一个小的,虚拟寻址的缓存,其中每一行都保存着一个由单一PTE组成的块,通常由高度的相联度。
TLB的存在是为了加快从内存或者cache中去出页表条目PTE的时间,做出的一个缓存。
页表可能由于虚拟地址较大,页表条目较大,页表大小较小的缘故,而非常大,占用内存空间,因此多级页表应运而生。
这里将虚拟地址的VPN进行划分成小段进行对不同级的页表进行查询,进而去除无用的页表项,减少对内存的占用。
前面的流程不变,只是再进行内存访问时需要加上cache(S,E,B,m)的访问
其中页表的访问与上述描述一致,唯一不同就是当得到物理地址之后进行的内存访问有区别,大致分为一下三个步骤。
如果不命中就要在下一层去请求块,然后将新块覆盖旧块。
图 7.3 core i7地址翻译及内存访问的概况
当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中每个区域结构都标记为私有的写时复制。其内存映射如图:
图 7.4fork函数
加载并运行hello,execve函数的内存映射大致4步骤
1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2)映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3)映射共享区域。 hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的。然后再映射到用户虚拟地址空间中的共享区域内。
4)设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
图 7.5execve函数的内存映射
(缺页作为一种异常是故障类型,MMU试图翻译一个虚拟地址A去到内存请求一个PTE时发现该PTE是未缓存的,那么就会引发一个缺页异常,需要在内存中选择一个牺牲页,将新页覆盖旧页,并且修改PTE,然后异常处理程序返回,他重新启动缺页指令,此时就能正常找到页了。
在linux系统中的处理还会做出其他判断,如果程序执行过程中遇到了缺页故障,则内核调用缺页处理程序。处理程序会进行如下步骤:检查虚拟地址是否合法,如果不合法则触发一个段错误,程序终止。然后检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。在两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。
(以下格式自行编排,编辑时删除)
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但不失通用性,假设堆是一个请求二进制零的区域,紧接在未初始化数据区域后开始,向上生长。对每个进程,内核维护一个全局变量brk指向堆顶。分配器将堆视为一组不同大小的块的集合来维护。
放置已分配块:当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。执行这种搜索的常见策略包括首次适配、下一次适配和最佳适配等。
分割空闲块:一旦分配器找到了匹配的空闲块,需要决定分配这个空闲块中多少空间。可以选择用整个块,但会造成额外的内部碎片;也可以选择将空闲块分割为两部分,第一部分变成已分配块,剩下的变成新的空闲块。
获取额外的堆内存:如果分配器不能为请求块找到空闲块,分配器通过调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插到空闲链表中,然后被请求的块放在这个新的空闲块中。
合并空闲块:分配器释放一个已分配块时,要合并相邻的空闲块。分配器决定何时执行合并,可以选择立即合并或者推迟合并。合并时需要合并当前块和前面以及后面的空闲块。
本节首先介绍了不同地址空间的概念与关系,然后介绍了怎么一步一步从逻辑地址到虚拟地址再到物理地址的,主要是经过了段式管理和页式管理两大方式,然后就页式管理我们给出了hello程序整个的一个流程其中包括MMU,TLB,PTE,Cache等概念并给出了完成的实现流程,最后介绍了fork,exceve函数是现实的内存映射的过程,并介绍了动态内存分配管理的机制与策略。
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备都被模型化为文件,而所有的输入和输出都被当作相应文件的读和写来完成,这种将设备优雅的映射为文件的方式,允许Linux内核引出一个简单的,低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
fd是需要关闭的文件的描述符(C中表现为指针),close 返回操作结果。
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
[转]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码。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
图 8.6getchar函数
可以看到getchar的底层实现是通过系统函数read实现的。getchar通过read函数从缓冲区中读入一行,但是只返回读入的第一个字符,若读入失败的话,会返回EOF。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中。
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数。
学完计算机系统我才知道,一个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格式文件