摘 要
hello.c几乎是每个程序员接触的第一个程序,这个程序的源文件不过几行,执行起来,瞬息之间完成。
然而,hello从一个文本,到在计算机系统上运行,必须通过预处理、编译、汇编、链接这些“人生坎坷”。并且在运行的过程中经历各种“跌宕起伏”,最终被回收,走向“生命的终点”。
为了深入理解这个具体过程,结合理论知识、运用相关工具,在本大作业中我们将对hello的从生到死的生命周期进行深入剖析和探究,进而能更好的理解相关过程的实现及原理。
关键词:计算机系统;编译;汇编;链接;目标文件;信号处理;进程;I/O函数;
目 录
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 5 -
1.4 本章小结 - 5 -
第2章 预处理 - 6 -
2.1 预处理的概念与作用 - 6 -
2.2在Ubuntu下预处理的命令 - 6 -
2.3 Hello的预处理结果解析 - 6 -
2.4 本章小结 - 7 -
第3章 编译 - 7 -
3.1 编译的概念与作用 - 7 -
3.2 在Ubuntu下编译的命令 - 7 -
3.3 Hello的编译结果解析 - 8 -
3.4 本章小结 - 11 -
第4章 汇编 - 14 -
4.1 汇编的概念与作用 - 14 -
4.2 在Ubuntu下汇编的命令 - 14 -
4.3 可重定位目标elf格式 - 14 -
4.4 Hello.o的结果解析 - 16 -
4.5 本章小结 - 17 -
第5章 链接 - 18 -
5.1 链接的概念与作用 - 18 -
5.2 在Ubuntu下链接的命令 - 18 -
5.3 可执行目标文件hello的格式 - 18 -
5.4 hello的虚拟地址空间 - 20 -
5.5 链接的重定位过程分析 - 21 -
5.6 hello的执行流程 - 21 -
5.7 Hello的动态链接分析 - 22 -
5.8 本章小结 - 23 -
第6章 hello进程管理 - 25 -
6.1 进程的概念与作用 - 25 -
6.2 简述壳Shell-bash的作用与处理流程 - 25 -
6.3 Hello的fork进程创建过程 - 26 -
6.4 Hello的execve过程 - 26 -
6.5 Hello的进程执行 - 26 -
6.6 hello的异常与信号处理 - 27 -
6.7本章小结 - 28 -
第7章 hello的存储管理 - 30 -
7.1 hello的存储器地址空间 - 30 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 30 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 30 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 31 -
7.5 三级Cache支持下的物理内存访问 - 33 -
7.6 hello进程fork时的内存映射 - 33 -
7.7 hello进程execve时的内存映射 - 34 -
7.8 缺页故障与缺页中断处理 - 34 -
7.9动态存储分配管理 - 35 -
7.10本章小结 - 36 -
第8章 hello的IO管理 - 39 -
8.1 Linux的IO设备管理方法 - 39 -
8.2 简述Unix IO接口及其函数 - 39 -
8.3 printf的实现分析 - 39 -
8.4 getchar的实现分析 - 40 -
8.5本章小结 - 41 -
结论 - 42 -
附件 - 43 -
参考文献 - 44 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
1.P2P:‘Program to Process’
Hello.c于文本编辑器被编写,是一个纯粹的文本文件。
经过cpp预处理、ccl编译、as汇编、ld链接,生成可执行目标程序hello。
执行程序hello时通过fork函数产生子进程,这就是P2P(从程序到进程)。
2.020:‘zero to zero’
shell会加载并运行hello,其过程是:fork得子进程,为它execve,调用加载器,映射虚拟内存,然后跳转到 main函数起始地址执行实现程序,并按照进程切换方式工作。
当程序运行结束后,进程会保持在终止的状态中,父进程对hello进行回收,内核删除相关数据结构,
重新回到原来的状态,不留下一丝痕迹。这就是zero to zero。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:X64 CPU;3.4GHz;8.0G RAM
软件环境:Windows7 64位;VMware;Ubuntu
开发工具:Ubuntu下GDB、edb 、readelf、vim
1.3 中间结果
hello.c c语言源文件
hello.i hello.c文件预处理后的文件
hello.s hello.i文件经过编译之后得到的文件
hello.o hello.s汇编之后得到的可重定位目标文件
hello.o.elf hello.o的elf格式文件
hello.elf hello可执行目标文件的elf格式文件
hello hello.c经过编译之后得到的可执行目标文件
1.4 本章小结
本章高度概括地讲述了hello从生致死的“一生”,让我们对hello的“生平”进行一个简略的了解。
简略地解释了hello.c如何从一个c语言文本一步一步转化为一个可以执行的目标文件。
第2章 预处理
2.1 预处理的概念与作用
预处理程序(cpp):对源程序中以字符#开头的命令进行处理。
作用:
①文件包含处理:将源文件中用#include形式声明的文件复制到新的程序中。
②处理宏定义:把#define定义的字符串,用真实值取代。
③处理条件编译:#if如果给定条件为真,则编译下面代码。
结果:得到另一个源程序文件,以.i为扩展名。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
解析:
1.打开hello.i文件,发现源文件增加到三千多行,main函数位于尾部。
2.hello.i不存在任何如#define、#include的#语句。
3.hello.c中对应的注释被去掉了。
4.依然C语言可读(是一个源文件)。
2.4 本章小结
本章主要介绍了预处理的概念、作用和结果。
预处理是对hello.c进行头文件展开、宏替换、去掉注释、条件编译,完成对源程序最基础的“替代”活动。经过此种替代,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件hello.i。
这都是为接下来的编译、汇编、链接等做出的最基础的准备。
第3章 编译
3.1 编译的概念与作用
注:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
编译程序(ccl):对预处理后的源程序进行编译。
作用:将代码翻译为汇编语言,以便汇编器翻译为机器码。汇编语言程序中的每条语句都以一种标准的格式确切地描述一条低级机器语言指令。
结果:生成一个汇编语言的源程序文件,以.s为扩展名。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1数据
hello.c含有全局变量,局部变量,外部变量,字符串常量。
(1)全局变量sleepsecs:
如图所示,
在.text段,sleepsecs被声明为globl;
在.data段,设置了其对齐方式、类型、大小、值。
(2)局部变量i:
i、argc、argv不会像全局变量一样单独存储,而是会在栈中留出空间, 存储在栈中,通过寄存器来传递。
如图所示,i被放在%rbp-4栈空间里。
(3)外部变量argv,argc:
如图所示,开辟了32字节大小的空间,第一个参数argc存在-20(%rbp)位置,数组argv从-32(%rbp)的位置开始存储。
(4)常量字符串:
被存放在.rodata节中
3.3.2赋值
hello.c中有两处赋值:
(1)全局变量sleepsecs初始化为2.5:直接在.data段声明sleepsecs值为2;
(2)局部变量i初始化为0:利用mov将0赋值给存在栈中-4(%rbp)位置的i。
3.3.3类型转换
hello.c中,存在一处隐式转换:int sleepsecs=2.5
即将2.5这个浮点型转化为整形,浮点数向整形转化的规则是向0舍入,因此最终得到int型变量sleepsecs值为2。
3.3.4算数操作
hello.c中含有一处算数操作及for循环中的i++操作,在hello.s中用add指令实现。
3.3.5关系操作
hello.c中有两处关系比较操作。
(1)if语句中,argc!=3:通过cmpl指令比较argc和3,若相等,跳转至L2,若不相等,即符合if(argc!=3),执行printf语句。
(2)For语句中,i<8:先用cmpl指令,判断i和7的大小关系,再用jle(小于等于则跳转)指令:若i<=7,即i<8,则跳转至L4,进入循环体;否则为i>=8,不符合条件,跳出循环。
3.3.6数组操作
hello.c中的数组操作如下图所示。
读argv[1],argv[2]在hello.s中操作:argv中各元素为8字节,以argv为基址,通过addq指令,得到argv[2]的地址,并用mov指令读取地址中的值。同理读取argv[1]。
3.3.7控制转移
hello.c中的控制转移包括if语句和for语句。
(1)if语句:通过cmpl指令比较argc和3,若相等,跳转至L2,若不相等,即符合if(argc!=3),执行printf语句。
(2)For循环:
3.3.8函数操作
hello.s中存在五个函数操作:
(1)mian函数:程序的主函数,call指令调用该函数。
参数为argc和argv,第一个参数用rdi保存,第二个参数用rsi保存,之后再复制到栈内;返回值在%rax中。
运行时申请栈空间,返回时恢复栈空间。
Ret时返回已压入栈中的要执行的下一条指令的地址。
(2)printf函数:call指令调用该函数。
第一次调用将第一个字符串的首地址传到%rdi 。 第二次调用将第二个字符串的首地址传到%rdi,并将 argv[1]、argv[2]分别存到%rsi 、%rdx。
(3)sleep函数:
参数为sleepsecs,存在edi中,用call指令调用函数。
(4)exit函数
参数代表退出状态,0为正常退出,仅一次调用,将参数1传给rdi,当控制流传递到exit当中时,就会从rdi寄存器中取出1这个数,然后当作退出的状态值。
(5)getchar函数:
无参数,作用是等待缓冲区用户输入。
3.4 本章小结
本章从汇编指令、数据、操作三个方面逐步深层次地分析了编译器如何编译hello.i文件形成汇编语言构成的hello.s文件,以便之后翻译成机器码得以运行。(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编程序(as):对汇编语言源程序进行汇编,将汇编语言翻译成二进制的机器码的过程成为汇编。
作用:把汇编语言代码翻译成目标机器指令,生成一个可以被机器读懂其指令的文件。
结果:得到一个二进制文件,其扩展名是.o,它是不可读的。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
得到hello.o的elf格式
(1)Elf头:位于目标文件的起始位置,包含文件结构说明信息。
以图为例,最开始的4字节为魔数,来标记是否为elf文件。
hello.o文件中,elf头长度64字节,因为是可重定位文件,所以字段E-entry为0,无程序头表(size of program headers=0),节头表离文件起始处的偏移量为1152字节,字符串表在节头表中的索引为12。
(2)节头表
节头表由若干个表项组成,每个表项描述相应的一个节的节名、在文件中的偏移、大小、访问属性、对齐方式等。
(3)节
节是elf文件中的主体信息,包含了连接过程中所用的目标代码信息,包括指令、数据、符号表和重定位信息等。
可重定位目标文件eld格式包括以下节:
.text,.rodata, .data, .bss, .symtab, .rel.text, .rel.data, .debug, .line, .strab
(4)重点:.rela.text节:text节相关的可重定位信息
当连接器将某个目标文件和其他目标文件组合时,.text节中的代码被合并后,一些指令中引用的操作数地址信息或跳转目标指令位置信息等都可能要被修改。通常地,调用外部函数或引用全局变量的指令中的地址字段需要修改。
(5)符号表
程序中定义的函数和全局静待变量名都属于符号,与这些符号相关的信息都保存在符号表中。
4.4 Hello.o的结果解析
机器语言:机器语言是一种指令集的体系。这种指令集,称机器码,是电脑的cpu可直接解读的数据。指令是控制计算机完成一个操作的命令。每个机器指令对应一个二进制数0和1组成的代码,是处理器能够直接执行的命令。一个机器语言程序就是一段二进制代码序列。
实际上hello.o只是一串由1,0排列的机器码。
objdump -d -r hello.o > hello.asm 将hello.o反汇编。
hello.s和hello.o的反汇编对比:大体相同
不同之处:
(1)操作数:hello.asm中,十六进制形式;hello.s中是十进制。
(2)跳转指令:hello.asm中,跳转后面直接接的是一个确切的地址,而在hello.s中是接的符号。
(3)函数调用:在hello.s中,call之后是要调用的函数名,而在hello.o的反汇编中,call之后是下一条指令确定的地址,以及一条重定向条目。
4.5 本章小结
本章主要是阐述hello从hello.s变成hello.o的过程,其中重点说明了ELF格式中的各部分(尤其是rela.text部分),并将原来的hello.s和hello.o进行反汇编的代码进行了比较。
第5章 链接
5.1 链接的概念与作用
链接阶段:链接程序(ld)将多个可重定位目标文件和标准函数库中的可重定位目标文件合并成为一个可执行目标文件。
作用:将目标文件、启动代码、库文件等链接成可执行文件。链接使得软件可以分离编译,可把应用程序分解为更小更好管理的模块,独立修改和编译这些模块。
结果:得到一个可执行目标文件,最终的可执行文件被保存在磁盘上。
5.2 在Ubuntu下链接的命令
命令:ld -o hello.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 hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
5.3 可执行目标文件hello的格式
(1)ELF头
对比可重定位文件(hello.o)的elf头,文件类型被改为可执行目标文件,入口点地址、程序头起点被确定,节头数量、字符串表索引节头发生变化。
(2)节头表
节头表描述了各个节的位置、大小、在程序中的偏移量等信息。
对比hello.o的节头表,可重定位部分(rela.text和rela.data都没有信息了)
(3)符号表
(4)程序头表
5.4 hello的虚拟地址空间
在edb的data_dump窗口中查看hello的虚拟地址空间,可以看到在0x00400000处程序开始被加载。
与5.3(2)中的节头表对比,可以看到.interp的起始地址是0x4001c8我们可以在data dump窗口中查看这个节的内容具体如上图所示,同时我们可以按照相同的方法来查看其他节。
5.5 链接的重定位过程分析
objdump -d -r hello.ld > hello.asm0
对比hello.asm0和hello.asm,可以看到两者之间的不同主要在于
1、 hello中多了一些节如.init .plt节
2、 hello中引入了一些外部函数
3、 hello.o中跳转和函数的调用在hello中被具体确定,是一个明确的虚拟内存地址。
通过两者的比较可以看出链接器的功能:
1.函数调用。动态链接器将/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o中的必要函数链接到hello中(如刚刚的.init中的初始化函数);并从动态链接共享库libm.so中调用hello.c中用到的printf、exit等函数(此类函数在共享库中被定义)。
2.rodata的引用。动态链接库面对重定位后的两个R_X86_64_PC32类型的.rodata,因为.rodata与.text相对位置确定,链接器会计算出相对距离的差值并直接更改函数调用指令处call后的地址,从而实现对.rodata的引用(hello中.rodata中保存的是之前提过多次的两个字符串,因此只是为两个字符串重定条目)。
5.6 hello的执行流程
init (argc=1,argv=0x7fffffffde38,envp=0x7fffffffde48)
in _start () 0x4004d0
in __libc_start_main@plt () 0x400480
in __libc_csu_init () 0x400670
in _init () 0x400430
in frame_dummy () 0x4005b0
in register_tm_clones () 0x400540
in main () 0x4005f2
in puts@plt () 0x400460
in exit@plt () 0x4004a0
in sleep@plt 0x4004f0
in getchar@plt 0x4004d0
in __do_global_dtors_aux () 0x400580
in deregister_tm_clones () 0x400500
in _fini () 0x4006e4
5.7 Hello的动态链接分析
PIC函数的调用:
由于函数调用了由共享库定义的函数,编译器没有办法预测这些函数的运行时的地址,因为他定义的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定技术解决这个问题——把函数地址的解析推迟到它世纪被调用的地方。
此时,因为程序还没有执行动态链接所以GOTPLT表是空的。执行完dl start后。发现GOT表中的数据发生了改变。
当程序调用一个动态链接库内定义的函数时(例如printf),控制流会跳转到该函数对应的PLT表中,PLT会通过GOT间接地把将要调用的函数的序号压入栈中,即动态链接器地一个参数,然后,调用动态链接器。
然后,动态链接器会进行重定位,用栈中的地址重写GOT,替代了GOT原先用来跳转到PLT的地址,变为了真正的函数地址,再把控制传递回调用函数,控制传递到PLT,再次通过GOT间接跳转,这次直接到了被调用函数的地址,这样完成了动态链接的过程。
!
5.8 本章小结
本章主要介绍了链接的概念、作用、过程,以及hello的ELF格式。
经过以上(2-5章)过程,hello.c终于修炼完毕,从源程序变成一个可以立即执行的程序,接下来就看它如何在计算机系统运行上展露锋芒了。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:一个执行中程序的实例。
进程的作用:
提供两个假象:1)独立的逻辑控制流2)私有的地址存储空间
使得在系统中运行程序时好像我们的程序是系统中当前运行的唯一的程序,我们的程序好像独占使用处理器和内存 。
6.2 简述壳Shell-bash的作用与处理流程
Shell:是一个交互型的应用级程序,代表用户运行其他程序。
处理流程:
1)读取用户由键盘输入的命令行。
2)分析命令,以命令名作为文件名,并将其它参数改造为系统调用execve( )内部处理所要求的形式。
3)终端进程调用fork( )建立一个子进程。
4)终端进程本身用系统调用wait4( )来等待子进程完成(如果是后台命令,则不等待)。当子进程运行时调用execve( ),子进程根据文件名(即命令名)到目录中查找有关文件(这是命令解释程序构成的文件),将它调入内存,执行这个程序(解释这条命令)。
5)如果命令末尾有&号(后台命令符号),则终端进程不用系统调用wait4( )等待,立即发提示符,让用户输入下一个命令,转⑴。
如果命令末尾没有&号,则终端进程要一直等待,当子进程(即运行命令的进程)完成处理后终止,向父进程(终端进程)报告,此时终端进程醒来,在做必要的判别等工作后,终端进程发提示符,让用户输入新的命令,重复上述处理过程。
6.3 Hello的fork进程创建过程
在终端中输入./hello执行hello程序。shell会先判断是否为内置命令,发现不是内置命令后,就把这条命令当作一个可执行程序的名字。shell调用fork函数创建一个新的子进程,这个子进程继承了父进程所打开的文件(屏幕、键盘等),创建父进程数据的一个副本,与父进程有着相同的虚拟空间地址,但是,子进程的PID与父进程不同。子进程运行结束后通常由父进程通过函数wait或waitpid回收。
6.4 Hello的execve过程
shell fork的子进程会调用execve函数在当前进程的上下文中加载并运行hello程序。execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。
Execve执行一次,并且从不返回。(除非出现错误)
6.5 Hello的进程执行
操作系统内核使用一种称为上下文切换的异常控制流来实现多任务。所谓上下文就是内核重新启动一个被抢占的进程所需的状态。当hello没有被别的进程抢占时,也就是他处于自己的进程时间片,此时按照hello程序顺序执行hello,当有别的进程进行抢占时,就会发生上下文切换,内核进行调度,保存hello进程上下文恢复一个之前被抢占的进程上下文,并将控制转给这个进程。
内核通过上下文切换机制实现将控制转移到新的进程。
上下文切换:
6.6 hello的异常与信号处理
正常运行时:会打印8次,且每两次之间有一定停顿时间。
在ctrl+z之后,进程停止。
这是因为,ctrl+z传入了一个SIGSTP信号,使程序挂起。
ps查看程序运行状态
通过kill -s 9 pid指令杀死对应进程,再查看发现该进程已终止。原因是kill向进程发送了终止信号。
如果没有杀死程序,由于程序只是被挂起,可以通过fg指令可以让其继续运行,可以看到程序继续运行,且挂起前、以及fg后相加共输出8次。
Pstree可以得到所有进程的关系
6.7本章小结
本章主要介绍了hello的进程管理,在hello的运行过程中,shell为他fork创建子进程、为他execve加载运行。经过这些步骤,hello运行了起来。
同时介绍了异常和信号通知,在hello一生结束时,shell回收了他,悄悄地,他走了,不带走一片云彩。。
第7章 hello的存储管理
7.1 hello的存储器地址空间
地址空间:地址空间是一个有序的集合(非负整数地址)。若地址空间的整数是连续的,我们称之为线性地址空间。
逻辑地址:程序代码经过编译后出现在汇编程序中地址,由选择符和偏移量组成。
物理地址:计算机系统的主存被组织成一个由M个连续字节大小的单元组成的数组。每字节都有一个唯一的物理地址。CPU通过地址总线寻址就是物理地址。
虚拟内存:为每个进程提供一个大的,一致的私有地址空间。
虚拟地址:虚拟内存被组织成一个由放在磁盘上N个连续字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址。磁盘上数组的内容被缓存到主存中。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符, 段内偏移量。给定一个完整的逻辑地址[段选择符:段内偏移地址],1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再依据对应寄存器,得到其地址和大小。我们就有了一个数组了。2、拿出段选择符中前13位,能够在这个数组中。查找到相应的段描写叙述符,这样。它了Base。即基地址就知道了。3、把Base + offset,就是要转换的线性地址了。
7.3 Hello的线性地址到物理地址的变换-页式管理
页表是一个页表条目的数组,虚拟地址空间中的每个页在页表中一个固定偏移量处有一个PTE,为了我们的目的,我们将假设每个PTE是由一个有效位和地址段组成,设置有效位,地址字段表示相应物理页起始位置。没有设置,空地址代表虚拟页未分配,否则指向虚拟页在磁盘起始位置。
而翻译地址过程是由MMU来进行的。CPU中一个控制寄存器,页表基址寄存器指向当前页表。一个n位虚拟地址包含两个部分,1个p位的虚拟页面偏移,一个N-p位的虚拟页号。MMU利用VPN也就是虚拟页号来选择适当的PTE,将页表条目中的物理页号和虚拟地址中的VPO串联起来,就得到了相应的物理地址。下图描述了整个过程。
7.4 TLB与四级页表支持下的VA到PA的变换
Intel Corei7是利用TLB和四级页表来进行这种变换的,以Intel Corei7为例子进行说明。
对于一个虚拟地址VPN为36位,VPO12位,虚拟地址48位。TLBI 4位,TLBT32位。我们利用下面的图进行说明。
CPU生成一个48位的虚拟地址,根据TLBI向TLB中进行匹配,如果命中则直接将PPN和VPO组合形成物理地址。如果匹配不命中,则向页面中进行查询,CR3确定了第一级页表的起始地址,VPN1确定了第一级页表的偏移量,如此找到了PTE,如果在物理内存中符合权限,则就已经确定了第二级页表的起始地址,再利用VPN2确定第二级页表中的偏移量,找到一个PTE重复上述操作,最后会在第四级页表中找到PPN,将PPN和VPO结合,找到了物理地址。
7.5 三级Cache支持下的物理内存访问
当CPU向L1高速缓存请求一个字的时候,高速缓存会按下面的步骤取出这个字并返回给CPU:
1)组选择:抽取出s个组索引位,这些位被解释为一个对应于一个组号的无符号整数,来进行组索引;
2)行匹配:对该组每行的标记为进行匹配;
3)字抽取:行匹配成功,且有效位为1时,根据地址中的块偏移位找到对应字的地址并取出;
7.6 hello进程fork时的内存映射
内核复制了当前进程的mm_struct、区域结构以及页表作为副本,来给这个新进程创建虚拟内存,并把两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记位私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建信也米娜。因此,也就为每个进程都保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
以hello程序举例,execve函数在当前进程中(子进程)加载并运行包含在可执行文件hello.out中的程序,加载并运行hello经过了以下几个步骤
7.8 缺页故障与缺页中断处理
缺页故障:若CPU引用了VP3中的一个字,但VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。
缺页中断处理:缺页异常调用内核中的缺页异常处理程序,该程序会选择-一个牺牲页,在此例中就是存放在PP 3中的VP4。
如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论如何,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。
内核从磁盘复制VP 3到内存中的PP 3,更新PTE 3,随后返回。
当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。
但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分为显式分配器、隐式分配器。
(1)隐式空闲链表:
对隐式空闲链表的每个块的结尾添加一个脚部(边界标记),其中脚部就是头部的一个副本。如果每个块都包含这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。
(2)显示空闲链表:将堆组织成一个双向空闲链表,在每个空闲块中,都包含一个pred前驱和succ后继指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。释放一个块的时间可以是线性的,也可以是一个常数,这取决于我们所选择的空闲链表中块的排序策略:
1)后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。分配器会最先检查最近使用的块。释放一个块可以在常数时间完成。
2)按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章主要介绍了hello的存储管理。Linux将磁盘文件抽象为虚拟内存用来管理,页表PTE用于管理这些虚拟内存,TLB是页表的高速缓存。
通过MMU,将虚拟地址翻译为物理地址,实现对数据的访问。最后为了实现主动对内存的管理,简单介绍了动态内存管理的办法。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件是C语言和Linux管理的思想,所有的IO设备都被抽象为文件,所有的输入输出都作为对文件的操作。这个设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得输入和输出都能以一种统一且一致的方式的来执行。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O接口:
(1)打开文件。
一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
(2)Linux shell
创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量,它们可用来代替显式的描述符值。
(3)改变当前的文件位置。
对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为k 。
(4)读写文件。
一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k>=m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。
类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
(5)关闭文件。
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
8.2.2 Unix IO函数:
(1)打开文件、创建一个新的文件
int open(char *filename, int flags, mode_t mode);
O_RDONLY只读;O_WRONLY只写;O_RDWR可读可写;O_CREAT如果文件不存在,就创建它的一个截断的文件;O_TRUNC如果文件已存在,就截断它;
O_APPEND在每次写操作前,设置文件位置到文件的结尾处。
(2) 关闭文件
int close(int fd);
(3)读一个文件
ssize_t read(int fd, const void *buf, size_t n);
从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示实际传送的字节数量。
(4)写一个文件
ssize_t write(int fd, const void *buf, size_t n);
从内存位置buf复制最多n个字节到描述符fd的当前文件位置。
8.3 printf的实现分析
研究printf的实现,首先来看看printf函数的函数体:其中,va_list是一个字符指针,arg表示函数的第二个参数。
接下来是vsprintf函数:显然,vsprintf返回的是要打印出来的字符串的长度,
接下来讨论write函数:功能是把buf中的第i个元素写到终端中。
追踪write得到一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
sys_call实现如下:syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示
8.4 getchar的实现分析
Getchar实际上是read的包装函数,read函数第一个参数文件描述符为0,意思是标准输入;第二个为输入内容指针,第三个为输入内容个数。read返回字符个数,当个数不为1时出错,返回eof;否则返回内容的指针。
异步异常-键盘中断的处理:用户按键后,键盘接口得到一个键盘扫描码,发送一个中断,开始执行键盘中断处理子程序,子程序接受按键扫描码将其转成ascii码,保存到系统的键盘缓冲区。
接着getchar等调用read系统函数,read函数从描述符为fd的当前文件位置复制最多n个字节到内存buf。返回值为-1表示一个错误,返回0表示EOF,否则返回值表示的是实际传送字节数,通过系统调用read从缓冲区读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux的IO设备管理方法、接口及相应函数和printf与getchar两个函数的实现分析。Linux将IO设备抽象为文件,提供了IO接口。通过接口,程序能够简单的对设备进行调用,这就是Linux系统的思想之一。
结论
hello从诞生到消亡的生命周期中经历了:
1.通过IDE输入代码,写成了了c格式源文件hello.c。
2.从一个高级c语言程序开始,然而为了在系统上运行hello.c程序,必须通过预处理、编译、汇编、链接这些过程转化成可执行的目标文件。
3.在终端输入“./hello”,运行hello程序。Shell通过fork为它创建新的子进程,在子进程中,通过execve函数将hello程序加载到内存。
3.通过Linux的虚拟内存管理,操作系统的时间片分配,信号接受处理机制,IO输入输出等,hello程序在计算机中游刃有余的输出到屏幕,读取共享库中的代码并使用。
4.程序运行结束后,shell将进程回收。
这是短暂但相当完整的一生!
附件
hello.c C语言源文件
hello.i 用来分析预处理过程的作用
hello.s 用来分析编译阶段的作用
hello.o 可重定位目标文件
hello.o.elf 可重定位目标文件ELF文件
hello.asm 可重定位目标文件反汇编
hello 可执行的目标文件
hello.elf 可执行的目标文件ELF格式
hello.asm0 可执行的目标文件反汇编
参考文献
[1] 兰德尔E, 布莱恩特. 深入理解计算机系统[M]. 北京:机械工业出版社, 2016…
[2] rabbit_in_android.虚拟地址、逻辑地址、线性地址、物理地址[EB/OL]. https://blog.csdn.net/rabbit_in_android/article/details/49976101.2015-2-22-2018-12-31.
[3] printf函数实现的剖析https://www.cnblogs.com/pianist/p/3315801.html
[4] Pianistx.[EB/OL]. https://www.cnblogs.com/pianist/p/3315801.html.2013-9-11-2018-12-31.
[5] 柳叶春 https://blog.csdn.net/ylcangel/article/details/18145155
[6] Linux下kill的使用https://blog.csdn.net/qiuyoujie/article/details/78626982.