摘 要
摘要是论文内容的高度概括,应具有独立性和自含性,即不阅读论文的全文,就能获得必要的信息。摘要应包括本论文的目的、主要内容、方法、成果及其理论与实际意义。摘要中不宜使用公式、结构式、图表和非公知公用的符号与术语,不标注引用文献编号,同时避免将摘要写成目录式的内容介绍。
计算机系统是高度集成的一个相当复杂的系统,这个系统的实现有多重机制。
本文通过结束计算机中一个简单的hello程序从预处理一直到IO管理的整个过程中的实现细节,粗略介绍了计算机系统的机制,对其中一些关键的实现细节进行了相对详细的探究。基于hello的实现过程,本文梳理了一个计算机系统的整体运行流程,可供参考。
关键词:CSAPP P2P 编译 汇编 链接 进程 IO 代码
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
3.3.1 数据的处理(常量,变量,表达式等) - 10 -
6.2 简述壳Shell-bash的作用与处理流程 - 44 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 52 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 53 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 53 -
7.7 hello进程execve时的内存映射 - 56 -
第1章 概述
1.1 Hello简介
首先通过键盘向计算机输入一串代码,这串代码组合成了一个hello.c源文件。
接下来将源文件通过gcc编译器预处理,编译,汇编,链接,最终完成一个可以加载到内存执行的可执行目标文件。
接下来通过shell输入文件名,shell通过fork创建一个新的进程,然后在子进程里通过execve函数将hello程序加载到内存。虚拟内存机制通过mmap为hello规划了一片空间,调度器为hello规划进程执行的时间片,使其能够与其他进程合理利用cpu与内存的资源。
然后,cpu一条一条的从hello的.text取指令执行,不断从.data段去除数据。异常处理程序监视着键盘的输入。hello里面的一条syscall系统调用语句使进程触发陷阱,内核接手了进程,然后执行write函数,将一串字符传递给屏幕io的映射文件。
文件对传入数据进行分析,读取vram,然后在屏幕上将字符显示出来。
最后程序运行结束,shell将进程回收,完成了hello程序执行的全过程/
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
软件:
Visio Studio 2017
VMware
Ubuntu
edb
gdb
gcc
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
Hello.c |
Hello的c源代码 |
Hello.i |
源代码预编译产生的ascii文件 |
Hello.s |
Ascii文件编译后产生的汇编代码文件 |
Hello.o |
汇编后产生的可重定位目标文件 |
Hello_o-objdump-d.txt |
可重定位目标文件的对应汇编代码 |
Hello_o-objdump-d-r.txt |
可重定位目标文件的代码及重定位条目 |
Hello_o-readelf-a.txt |
可重定位目标文件的elf条目 |
Hello-ld |
链接生成的可执行目标文件 |
Hello-ld-readelf-a.txt |
可执行目标文件对应的elf条目 |
Hello-objdump-d-r.txt |
可执行目标文件对应的汇编代码 |
Hello-linkinfo.txt |
可执行目标文件的链接信息 |
1.4 本章小结
在本章节中,大致描述了一个hello程序从出生到去世的完整过程,以及我在描述整个过程时所使用的软件环境,和产生的中间文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理又称预编译,是指在对C源代码文件进行词法扫描和语法分析之前所做的工作。
预处理所做的主要工作,就是在对一个C源文件进行编译操作之前,对其进行一些预先的处理,包括删除注释,处理宏定义(#define),添加包含的头文件(#include),执行条件编译(#ifdef),一方面能够是生成的与处理文件能够便于编译器的直接处理,也使得编写的程序能够便于阅读,修改,移植和调试,有利于模块化程序设计。
2.2在Ubuntu下预处理的命令
预处理命令:gcc -E hello.c -o hello.i
2-1预处理的效果
2.3 Hello的预处理结果解析
右边为预处理前的hello.c源代码,左边为预处理后的hello.i文件,可以看到,源代码中的注释都被删除,而#include命令所包含的头文件都被替代为了相应的代码,这样产生的hello.i文件具有能够独立运行的一套源代码,而不是实现功能的代码片段了。
2-2预处理前后文件的比较
2.4 本章小结
如果说生成一个完整的可执行文件就像是造一辆跑车,那么c源代码就是跑车的图纸,而c文件的预处理就是按照图纸粗制一批合适的钢材。
通过对C文件的预处理,我们将c文件改编成了统一的格式,通过宏的处理对c文件进行了适当的修正,马上进入下一步的处理。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
限于C语言,编译就是将用C的语言写成的源代码文件,等价的翻译成汇编语言文件的过程。
编译能够将.i文件中的c代码,不改变其所实现的功能的过程和结果,同时又有一定的优化和修改,翻译成能够同等的完成其任务的一段汇编语言代码,
编译之前,C语言编译器会进行词法分析、语法分析(-fsyntax-only),接着会把源代码翻译成中间语言,即汇编语言。如果想看到这个中间结果,可以用-S选项。
编译程序工作时,先分析,后综合,从而得到目标程序。所谓分析,是指词法分析和语法分析;所谓综合是指代码优化,存储分配和代码生成。为了完成这些分析综合任务,编译程序采用对源程序进行多次扫描的办法,每次扫描集中完成一项或几项任务,也有一项任务分散到几次扫描去完成的。下面举一个四遍扫描的例子:第一遍扫描做词法分析;第二遍扫描做语法分析;第三遍扫描做代码优化和存储分配;第四遍扫描做代码生成。
值得一提的是,大多数的编译程序直接产生机器语言的目标代码,形成可执行的目标文件,但也有的编译程序则先产生汇编语言一级的符号代码文件,然后再调用汇编程序进行翻译加工处理,最后产生可执行的机器语言目标文件。
3.2 在Ubuntu下编译的命令
gcc -S hello.c -o hello.s
3-1 编译的效果
3.3 Hello的编译结果解析
3-2 编译前后文件的比较
3.3.1 数据的处理(常量,变量,表达式等)
c源代码中出现的数据如下:
3-1 源代码数据类型解析
- 全局变量的处理
当前代码中的sleepsecs整型变量就是一个全局变量。全局变量的特点是在C程序的任意函数中都能够直接读写,因此全局变量采用独立于函数之外的存储位置,汇编代码中,全局变量会被存放在函数体外的data段,在运行中通过GOT表进行引用。
3-2汇编代码中的全局变量
汇编代码中同时也对sleepsecs的数据类型进行描述,因为在汇编中是没有整型,浮点型这些概念的,有的只是一串连续的数据。
- 常量的处理
在汇编程序中,常量一般存放在专门的区域,需要的时候直接调用。为了链接的方便,一般会采取全局偏移量表(GOT)的形式来调用全局变量。
当前的C源代码中的常量主要是两个用于在printf中输出的字符串,这两个字符串直接存放在汇编程序中的只读数据域
3-3 汇编代码中的只读数据
- 局部变量与参数的处理
当前c代码中的用于计数的整型变量i就是局部变量,与main函数的参数argc和argv一样,这些数据都是只会在当前的局部函数中进行读写的,外部函数没有能够正常访问到这些数据的方法。因此不需要像全局变量那样在代码段外独立的为这些变量分配空间。
这类数据一般是在程序运行的栈中保存,寄存器中进行传递。同时在栈于寄存器中都可以对其进行修改。
因此,汇编代码为这些数据专门开辟了存储的栈空间。
3-4 开辟栈空间的汇编指令
分别将原本存储于寄存器中的argc于argv变量压入栈中进行管理
3-5将变量压入栈中
变量i也在栈中进行读写管理:
初始化i
3-6初始化i
对i进行累加:
3-7对变量累加
程序最后的栈空间的示意图如下:
3-8栈空间示意图
3.3.2 赋值语句的处理
c源代码中的赋值语句仅有一处:
3-9赋值语句
由于此处的局部变量i存放于栈中,汇编语言直接对栈的值进行修改:
3-10汇编代码中的赋值语句
3.3.3 算术操作
c源代码中的算术操作仅在for循环语句中有一处:
3-11算术操作
对应的翻译到汇编代码中的形式如下:
3-12汇编代码中的算术操作
3.3.4 关系操作
c源代码中的关系操作共有两处:
3-13关系操作
分别是argc参数与3进行比较,判断二者是否相等,以及局部变量i与10的比较,判断i的值是否小于10。
c语言中的关系操作表达式的值是根据关系的真伪来确定的,真为1,假为0。
而在c语言中,关系判断的结果通常用于改变控制流,如作为if语句的判断条件,以及for,while等循环语句中的循环条件。
因此在汇编语言当中,能够直接翻译成相应的条件跳转命令,来决定控制流的方向。
argc变量的关系操作所对应的汇编语言如下
3-14汇编代码中的关系操作
在这里,cmpl命令会怕判断立即数3与参数argc的关系,然后根据结果设置条件寄存器,而后面的je指令通过条件寄存器的值的组合来决定是否跳转。
在这里,如果满足条件argc!=3,就会直接执行下面紧跟着的语句,否则就会跳过这一段语句,直接开始执行L2处的语句。
同理,局部变量i的关系操作对应的汇编代码如下:
3-15汇编代码中的关系操作
只要i的值仍然小于10,就会不断地执行下面的跳转指令,从程序员的角度来看,控制流一直在for循环体内部不断地执行。
值得一提的是,c源代码中的语句是i<10 而这里的语句的等效C语句确是i<=9
由于编译器会对C代码进行优化,毫无疑问这里的c代码也是被优化了的状态,大概在编译器的眼中,判断<=的关系要比判断<的关系的效率更高吧。
这也提醒了我们,编译器产生的汇编代码不一定是c源代码的简单转换,而是会进行不同程度的优化,只是最后产生的运行结果没有改变罢了。
3.3.5 数组/指针/结构操作
指针是c语言编译的一个非常复杂而巧妙的部分。
当前程序中设计到数组/指针操作的代码如图所示:
3-16指针操作
通过对传入的字符串数组argv进行寻址来读取参数。
argv是从命令行键入的字符串的地址数组,里面按顺序存放着命令行输入的字符串在内存中的存放地址。
由于数组是在内存中一段连续的内存空间中进行存储的,所以汇编语言通过索引值与数组基址来对数组内容进行寻址。
argv[1]代表的就是数组中第2个参数的地址,程序员数数都是从0开始的,这难道不是常识吗?
对应的汇编代码如下:
3-17汇编代码中的数组索引处理
通过这样的转换来对数组按照索引进行寻址。
3.3.6 控制转移
在c语言中,产生控制流转移的情况有两种,分别是分支和循环,在当前函数的代码中对应了if分支判断语句和for循环语句。
3-18控制转移语句
在编译器将c语言中的控制转移语句翻译成为汇编语言时,会使用汇编中的条件判断与跳转指令来约束控制流,使控制流按照c语言所描述的行为来流动。
其中if语句的基本结构:
if(expr)
expression;
如果expr分支判断表达式为真,则执行下面的分支体,否则跳过。
翻译成汇编语言如下:
3-19汇编代码中的分支语句
for语句的基本结构:
for(init-expr; test-expr; update-expr)
body-statement;
按照这个结果等效产生的用goto语句描述的c语句如下:
init-expr;
goto test;
loop:
body-statement
update-expr;
test:
t=test-expr;
if(!t)
goto loop;
对应的汇编代码:
3-20汇编代码中的for循环语句
3.3.7 函数操作
c语言中的函数调用对应了汇编语言中的call指令,汇编语言与操作系统提供了一整套机制来保证函数多级调用的层进与参数的层层传递能够稳定进行。
- 首先是函数调用在系统层级的实现细节
在程序运行时,系统会为其提供一个上下文,通过进程机制与虚拟内存机制的配合,在程序看来,自己就好像独自占有内存,且独自占有cpu资源,有自己独立的控制流。这个前提保证了我们可以忽略系统背后复杂的机制来分析程序本身的运行过程。
基于以上的前提,首先看一下一个程序运行过程中的内存结构:
3-21程序的运行时内存结构
在程序运行的过程中,随着函数层层调用,栈不断往下生长。每个函数都会有一个运行时栈,栈中存放着当前函数运行时所需要的信息,包括局部变量,保存的寄存器。
可以这样想:一个程序的栈可以看作这个程序私有的小内存空间。
栈的先进先出的结构特点与函数的多层调用机制完美契合。
在这样的机制之下,一个函数调用另一个函数,就在调用函数的栈下面新开辟一个栈空间,而当被调用函数运行结束之后,释放栈空间,就又回到了原来的调用函数的栈空间。
当一个函数被调用的时候,需要记录下返回的地址,这样当这个被调用函数运行结束之后,才能跟顺址寻路,顺利的回到原来的地方继续未竟的事业。
因此当一个函数将要调用下一个函数时,就会将下一个函数调用完成后应该回到的地址放在栈顶,也就是下一个函数栈底一墙之隔的位置。
3-22程序的栈空间
这样当下一个函数执行完成之后,只要顺着自己的栈,就能够找到回家的路。
- 其次是系统层面的c参数传递机制
在栈调用机制的支持下,函数之间的传递参数也变得格外方便。
一个函数想在调用函数的时候传递参数,只需要简单的将参数放在自己的栈当中,下一个函数在运行的时候就可以通过先前函数的栈来读取参数。编译器以及保证了每个程序都能够正确的找到参数在自己父程序栈中的位置。
更进一步,当参数小于6个的时候,甚至可以不需要通过栈来传递参数,函数可以将自己的参数压入寄存器中,然后由下一个函数到寄存器中去取参数即可。
作为限制,编译器默认的设置了6个专门用来传递参数的寄存器,分别是rdi,rdi,rdx,rcx,r8,r9.当完成了参数传递的任务之后,这几个寄存器有可以当作普通的寄存器来使用。
就这样,通过系统,硬件,编译器,编程语言的相互配合,实现了一套方便的函数调用机制。
在当前程序中,执行函数操作的语句有这些:
3-23函数调用
调用了printf函数与exit函数
3-24函数调用
调用了printf函数与sleep函数
3-25函数调用
调用了getchar函数
在汇编语言中,简单的改用call指令就能够执行对函数的调用:
3-26汇编代码中的函数调用
第一条printf函数调用只传递了一个参数,汇编代码将这个参数传入参数寄存器rdi中,下一个执行的程序就能够直接从rdi寄存器中取值
3-27汇编代码中的函数调用
同理,上一条语句先将立即数1传入参数寄存器中,然后使用call指令调用exit函数。当控制流传递到exit当中时,就会从rdi寄存器中取出1这个数,然后当作退出的状态值。
3-28汇编代码中的函数调用
同理,这几条汇编语句总共将3个参数传入了参数寄存器当中,然后调用printf函数。
3-29汇编代码中的函数调用
用call指令调用getchar函数。
值得注意的是,在当前函数的汇编代码中都跟着一个@PLT符号,这个符号的意思是过程链接表,用于动态库的链接。关于这一部分,将会在后面的链接中继续讨论。
3.4 本章小结
前面我提到预处理后的文件相当于打造出的用于制造跑车的钢材,那么编译这一步就是将钢材细细打磨,变成尺寸严丝合缝的零件。
通过编译,函数的c代码变为了等效的汇编代码,编译器分别从c语言的数据,赋值语句,类型转换,算术操作,逻辑/位操作,关系操作,指针操作,控制转移与函数操作这几个关键点布局,从微观细节上剖析,宏观上调配,既符合了c的语义和用意,有很好的契合了计算机的底层机制。编译器简直就是艺术。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编会将编译产生的ascii码构成的汇编代码翻译成相对应的机器代码,即目标代码,也就是从人能够读懂的字符翻译成为cpu能够读懂的二进制程序码的过程。
当编译器将c源代码一路翻译成汇编代码之后,仍然不是及其可以读懂的格式。cpu在运行程序时通过机器码来判断所要执行的指令,因此还需要将ascii格式的汇编代码转化为机器码。
但需要注意的是,汇编仍然是一个中间过程。我们所编写的程序包含着在外部的库中定义的函数,同时也缺少从系统进入程序的中间函数。
更进一步,当代码越写越大之后,可能会出现更多的定义和引用分离的情况,例如一个函数在一个.c源文件中定义,而被另一个.c文件中的函数引用。在这种情况下,预处理到编译,不过是将单个的.c文件进行了翻译。
要想程序完整可用,还需要一个将多个文件合并成一个完整的可执行文件的过程,这个过程就是链接,而汇编就是在文件中根据汇编代码生成一些能够指引链接过程进行的数据结构。
形象的说,我们已经造好了一台汽车的所有零件,在将零件组装起来之前,我们现在要做的就是打造螺丝钉。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
4-1汇编的效果
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
4-2 elf格式
4-3 elf文件内容图示
一个典型的elf文件的格式如上图所示,根据这个模型,可以简单的分析hello.o文件的基本组成。
首先是ELF头,这个节存储了整个.o文件的一些基本定信息,具体如下图。
4-4 elf文件格式解析
接下来看看节头部表,这张表中存储了elf表中每一个节的具体信息,包括类型,名称,偏移值等。以此为索引,能够对elf文件中每一个具体的节进行访问。
4-5 elf文件的节头部表
.text节包含着已编译程序的机器代码,具体结构如下:
4-6 elf文件.text段
.rodata节含有例如printf语句中字符串这样的只读数据
.data存放已初始化的全局和静态C变量
.bss存放未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。
.symtab节存放着程序中的所有符号,包括被引用的以及被定义的。链接器可以通过这张表来获取当前可重定位目标文件中的符号信息,并以此来对文件进行链接。
其具体结构如下:
4-7 e文件.symtab节
.rel.text是代码段的重定位条目,每当汇编器发现程序中有未定义的引用或者在当前程序中定义而可能被外部程序所引用的符号(非静态的全局符号),就会为其生成一条重定位条目。
4-8 elf文件的重定位条目
在重定位节当中的每一个条目的每一条信息都会在链接的过程中用于重定位符号,修改引用,将多个可重定位目标文件连接成一个完整的可执行文件。通过可重定位条目中信息的指引,可以使链接器准确无误的对多个可重定位目标文件进行合并和修改,具体细节将在链接过程中具体探讨。
4.4 Hello.o的结果解析
通过objdump指令可以看到hello.o文件的.text段的具体情况,此时的.text段只是一串由1和0构成的机器码,将其对应的转化为汇编指令,会发现一些不同之处
4-9 可重定位目标文件与汇编代码的区别
共有以下几点不同:
- 跳转语句的操作数已经完成了计算
对比两端代码中的相同跳转语句:
4-10 11跳转语句对比
可以看到,在.o文件中,跳转的位置已经由符号指代变成了具体的数值。
由于不同文件代码链接合并和,一个文件本身的代码的相对地址不会改变,所以不需要与外部重定位,而可以直接计算出具体的数值,因此这里就已经完成了所有的操作,这条语句将以这种形式加载到内存中被cpu读取与执行。
- 对函数的调用与重定位条目相对应
4-11 重定位条目对比
可以看见,汇编代码文件中的call对函数调用的语句都是直接以函数名来指代,而在.o文件中取而代之的是一条重定位条目指引的信息。
由于调用的这些函数都是未在当前文件中定义的,所以一定要与外部链接才能够执行。
在链接时,链接器将依靠这些重定位条目对相应的值进行修改,以保证每一条语句都能够跳转到正确的运行时位置。
- 对全局变量的引用与重定位条目相对应
4-12 全局变量引用的重定位条目对比
由于全局变量在运行时的内存位置是未知的,所以同样需要生成一条重定位条目,提醒链接器在链接时谨慎的计算运行时的内存地址,然后分配给每一条引用,保证每一条引用最终都能够指向正确的位置
- 立即数变为16进制格式
在.o文件当中,立即数都变为16进制。因为计算机是基于二进制运行的,十六进制可以很方便的与二进制相互转化,因此这里更换成了16进制。
另一方面,我们利用objdump看到了翻译过来的汇编代码,但真实的.o文件里保存的其实只有机器码。
在现有的系统中,每一个汇编指令都与1个字节的十六进制码一一对应。
比如在这条语句中,mov指令对应的机器码是48,%rsp与%rbp寄存器对应的机器码分别是89和e5,当cpu读取到mov指令后,就马上解析出这是mov指令,而且后面会跟两个寄存器,因此又会继续读取后面的两个字节,并将其翻译成对应的寄存器,并进行操作。
每一个汇编指令对应的操作数的个数与种类都是确定的,因此一段汇编的机器代码只要确定一个起始位置,最终解析出来的操作序列是没有二义性的。
4.5 本章小结
要想造出一辆跑车,精密耐用的零件只是一个必要的方面。当零件齐全了之后,如何将零件组装起来,使得每个零件之间稳固,因此在组装之前,还需要将零件打磨一番。
汇编器对编译器生成的汇编代码文件更深一层,翻译成机器代码文件,也就是可重定位目标文件。由于每个文件中只有一部分的函数,且文件直接互相引用,互相依赖。与此同时,对于链接器来说,每个文件不过是一个字节块,要想解决这些字节块内部之间的互联逻辑,就需要汇编器多做一些,再将汇编代码翻译成机器代码时加入一些能够引导链接器进行链接的数据结构。至此,汇编器的工作就结束了,离成功不过寸步之遥。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
编译器与汇编器将C源文件简单的改写成等效的汇编代码文件,但是这个文件仍然不够完整,不是一个能够加载到内存中直接开始运行的状态。
因为此时的.o文件,即可重定位目标文件只是一个代码片段,包含了不完整的定义,要想将其变为一个完全可执行的状态,还需要进行链接
链接是将各种代码和数据片段收集并组合成一个单一文件的过程。
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
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
5-1 链接的过程
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
hello可执行文件的基本结构如下:
5-2 典型的elf可执行目标文件
通过readelf指令读出hello可执行目标文件的elf格式:
elf头:
5-3 可执行目标文件的elf头
节头部表:
5-4 节头
重定位节.rela.text:
5-5 重定位节
重定位节.rela.eh_frame:
5-6 符号节
与可重定位目标文件相比,可执行目标文件被设计成很容易加再到内存的格式。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
根据linux进程运行时加载到内存的格式的示意图:
5-7 linux进程的虚拟内存映射
通过readelf读取可执行目标文件的程序头表
5-8 目标文件的程序头表
LOAD段起始于0x400000, 表示一个从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串),程序的目标代码等等。
可以看到,内存中从地址0x0400040开始的一段区域是PHDR段,这一段主要用于保存程序头表
INTERP段起始于0x400200,同样也是只读数据,其主要作用是指定在程序已经从可执行映射到内存之后,必须调用解释器。在这里解释器并不意味着二进制文件的内存必须由另一个程序解释。它指的是这样的一个程序:通过链接其他库,来满足未解决的引用。
DYNAMIC段起始于0x600e50,保存了其他动态链接器(即,INTERP中指定的解释器)使用的信息。
NOTE保存了专有信息。
程序运行时,就会将相应的信息加载到内存中的对应位置
通过edb动态调试工具查看hello运行时的状态。
从data dump窗口可以看到地址0x0400000到地址0x0401000的运行时内容。
5-9 地址0x0400000到地址0x0401000的运行时内容
可以知道,在程序运行时,这一段区域所存储的内容就是只读数据,即.init,.test,.rodata段,包含程序运行的入口,程序开始运行时配置环境所要调用的系统函数,程序的主体代码以及执行程序时所要用到的一些只读数据。
查看0x0600000到0x0601000的内存内容
5-10 0x0600000到0x0601000的内存内容
查看0x0601000到0x0602000的内存内容
5-11 查看0x0601000到0x0602000的内存内容
5.5 链接的重定位过程分析
之前在解析hello.o文件时,提到了在重定位节当中存放的重定位条目。同时前面也提到过,我们通过预处理,汇编,编译产生的文件仍然只是最终完整的可执行文件的一部分。完成了hello.o文件的生成,相当于造出了跑车的发动机,发动机确实是跑车最重要的部分,但是只有发动机,跑车也无法工作。hello.o文件中的重定位条目就是指导链接器将其组装起来的说明书。
hello.o文件中的重定位条目如下:
5-12 hello.o文件中的重定位条目
通过objdump指令观察hello.o文件的.text段:
5-13 hello.o文件的.text段
为了方便,objdump工具已经自动将重定位条目放在了相应的位置。
hello可执行目标文件中多出了.init段和.plt段,前者用于初始化程序执行环境,后者用于程序执行时的动态链接,这里不再赘述。
5-14 两个文件的区别
进行链接之后两个文件的区别如上图,所有的重定位条目都被修改为了确定的运行时内存地址。
在执行这个链接过程之前,链接器已经通过可重定位目标文件中的符号表信息,确定的将每个符号引用都与一处符号定义对应了起来。汇编器生成的重定位条目指明了需要被修改的符号引用的位置,以及有关如何计算被引用修改的一些信息。
对于相对地址的引用,即图中的类型为R_X86_64_PC32的引用条目。
对于这类条目,首先确定其定义所在的节以及其相对于节的偏移量,通过这两个量计算出符号定义的地址,即ADDR(r.symbol)
接下来通过重定位条目指向的内存位置,对引用信息进行修改,使其指向内存运行时的地址,基本计算公式如下:
5-15 基本计算公式
对于R_X86_64_PLT32类型的引用是动态链接的,也就是在静态链接过程中只是简单的构造过程链接表(PLT)和全局偏移量表(GOT),然后在程序加载到内存里运行的过程中才会完成最终的重定位工作。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
使用gdb观察Hello的执行流程:
_init |
0x7ffff7a05920 |
_dl_vdso_vsym |
0x7ffff7b4b3b0 |
_dl_lookup_symbol_x |
0x7ffff7de00b0 |
do_lookup_x |
0x7ffff7ddf240 |
strcmp |
0x7ffff7df2360 |
do_lookup_x |
0x7ffff7ddf240 |
_dl_vdso_vsym |
0x7ffff7b4b3b0 |
_dl_lookup_symbol_x |
0x7ffff7de00b0 |
_dl_vdso_vsym |
0x7ffff7b4b3b0 |
__strrchr_avx2 |
0x7ffff7b723c0 |
__init_misc |
0x7ffff7b056f0 |
__GI___ctype_init |
0x7ffff7a148f0 |
_dl_init |
0x7ffff7de5630 |
init_cacheinfo |
0x7ffff7a05470 |
handle_intel |
0x7ffff7a9fe80 |
intel_check_word |
0x7ffff7a9fb80 |
_start |
0x400500 |
__libc_start_main |
0x7ffff7a05ab0 |
__new_exitfn |
0x7ffff7a27220 |
__GI___cxa_atexit |
0x7ffff7a27430 |
__libc_csu_init |
0x4005c0 |
__sigsetjmp |
0x7ffff7a22b70 |
Main |
0x400536 |
printf@plt |
0x4004c0 |
_dl_runtime_resolve_xsavec |
0x7ffff7dec750 |
_dl_fixup |
0x7ffff7de4df0 |
malloc |
0x7ffff7a052c6 |
sleep@plt |
0x4004f0 |
getchar@plt |
0x4004d0 |
5.7 Hello的动态链接分析
动态链接是一项有趣的技术。考虑一个简单的事实,printf,getchar这样的函数实在使用的太过频繁,因此如果每个程序链接时都要将这些代码链接进去的话,一份可执行目标文件就会有一份printf的代码,这是对内存的极大浪费。为了遏制这种浪费,对于这些使用频繁的代码,系统会在可重定位目标文件链接时仅仅创建两个辅助用的数据结构,而直到程序被加载到内存中执行的时候,才会通过这些辅助的数据结构动态的将printf的代码重定位给程序执行。即是说,直到程序加载到内存中运行时,它才知晓所要执行的代码被放在了内存中的哪个位置。
这种有趣的技术被称为延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。而那两个辅助的数据结构分别是过程链接表(PLT)和全局偏移量表(GOT),前者存放在代码段,后者存放在数据段。
首先通过readelf分析可执行目标文件,得到文件的GOTPLT的运行时位置:
使用edb对hello的运行过程进行解析,可以看到在运行_dl_start与_dl_init之前,GOTPLT表的内容如图所示:
5-17 GOTPLT表的内容
此时的PLT表还空空如也,因为程序还没有执行动态链接。
PLT时一个数组,PLT[0]跳转到动态链接器中,PLT[1]调用系统启动函数来初始化执行环境。直到PLT[2]开始的每个条目才是负责具体函数的链接的。
执行完dl start后。发现GOT表中的数据发生了改变。
5-18 GOT表中的数据发生了改变
GOT[1]= 0x00007f3198405170 指向重定位条目
GOT[2]= 0x00007f31981f3750 指向动态链接器
GOT[1]所指向的重定位表如下:
5-19 GOT[1]所指向的重定位表
GOT[2]指向的动态链接器如下所示:
5-20 GOT[2]指向的动态链接器
当程序需要调用一个动态链接库内定义的函数时(例如printf),call指令并没有让控制流直接跳转到对应的函数中去,由于延迟绑定的机制,此时的printf还不知道在哪儿呢。取而代之的是,控制流会跳转到该函数对应的PLT表中,然后通过PLT表将当前将要调用的函数的序号压入栈中,下一步,调用动态链接器。
接下来,动态链接器会根据栈中的信息忠实的执行重定位,将真实的printf的运行时地址写入GOT表,取代了GOT原先用来跳转到PLT的地址,变为了真正的函数地址。
于是,上一次控制流找过来时,GOT给它指的路是动态链接器,动态链接器将真正的地址给GOT表。
这一次控制流再找上门来的时候,GOT就可以放心的将真正的函数执行时地址传达过去,完成了动态链接的过程。
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
终于完成了所有的部件,组装跑车的过程总是激动人心的。但同样不容懈怠,哪怕有一丝疏忽,有一个零件装错了,在跑车高速运转的时候都会出现难以估计的灾难。
链接器在这里通过可重定位目标文件中的数据结构,解析每个文件中的符号,仔细比对了符号的定义和引用,最终为每个符号的引用都找到了正确的符号定义的位置。重定位的过程需要更加小心谨慎,链接器需要在特定的位置修改值,使得程序在运行时能够指哪打哪而不会偏差。毕竟在cpu中哪怕是一个字节的偏差,失之毫厘,差之千里。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程是计算机科学中最深刻、最成功的概念之一。
当hello程序在计算机中开始执行时,操作系统给了它一种假象,仿佛它是当前系统中唯一正在运行的程序一样,它独自占有一块完整的内存空间,cpu对它指令有求必应,处理器仿佛一直在执行hello这一个程序的指令。
这种状态就成为进程。
进程就是一个执行中的程序的实例,系统中每一个程序都运行在某个进程的上下文中,系统始终维护着这个上下文,使进程与上下文之间的互动天衣无缝。在操作系统的辛苦维持下,才给予了程序独自占用所有计算资源的假象。
进程提供给应用程序的关键抽象如下:
- 一个独立的逻辑控制流。
- 一个私有的地址空间。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个交互型的应用级程序,它代表用户运行其他程序。
shell首先打印一个命令行提示符,等待用户输入命令行,然后对命令行进行求值。shell的基本流程是读取命令行,解析命令行,然后代表用户运行程序。
shell首先调用parseline函数,通过这个函数解析以空格分隔的命令行参数,并构造最终会传递给execve的argv向量。
若第一个参数是内置的shell命令名,马上就会解释这个命令。如果不是,shell就会假定这是一个可执行程序,然后在一个新的子进程的上下文中加载并运行这个文件。
若最后一个参数是&,那么这个程序将会在后台执行,即shell不会等待其完成。
若没有,则这是一个将要在前台执行的程序,shell会显式地等待这个程序执行完成。
当作业终止时,shell就会开始下一轮迭代。
6.3 Hello的fork进程创建过程
Hello的执行是通过在终端中输入./Hello来完成的。
在linux系统下的终端中始终运行着一个Shell来执行用户输入的操作,作为用户与系统之间的媒介。
当我们在终端中输入./Hello时,shell会先判断发现这个参数并不是Shell内置的命令,于是久把这条命令当作一个可执行程序的名字,它的判断显然是对的。
接下了shell会执行fork函数。
fork函数的作用是创建一个与当前进程平行运行的子进程。系统会将父进程的上下文,包括代码,数据段,堆,共享库以及用户栈,甚至于父进程打开的文件的描述符,都创建一份副本。然后利用这个副本执行子进程。从这个角度上来说,子进程的程序内容与父进程是完全相同的。
6.4 Hello的execve过程
在父进程fork后,父进程重拾自己的老本行,继续运行shell的程序,而子进程将通过execve加载用户输入的程序。由于Hello是前台运行的,所以shell会显式的等待hello运行结束。
execve函数加载并运行可执行目标文件,且带参数列表argv和环境变量envp。只有当出现错误时,execve才会返回到调用程序,否则execve调用一次而从不返回。
execve的参数列表如下图:
6-1 环境变量列表的组织结构
在execve加载了Hello之后,它会调用系统提供的启动代码,启动代码设置栈,启动程序运行初始化代码。系统会用execve构建的数据结构覆盖其上下文,替换成Hello的上下文,然后将控制传递给新程序的主函数。
execve只是简单的更换了自己所处进程的上下文,并没有改变进程的pid,也没有改变进程的父子归属关系。
对于正在运行的Hello来说,除了自己的父进程是Shell之外,其它的一切都与调度运行没有区别。
6.5 Hello的进程执行
在Hello进程执行的时候,操作系统为其维持着上下文。Hello进程就是在其上下文中稳定运行的。
上下文是内核重新启动一个被抢占的进程所需的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表,包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
Hello进程在内存中执行的过程中,并不是一直占用着cpu的资源。因为当内核代表用户执行系统调用时,可能会发生上下文切换,比如说Hello中的sleep语句执行时,或者当Hello进程以及运行足够久了的时候。每到这时,内核中的调度器就会执行上下文切换,将当前的上下文信息保存到内核中,恢复某个先前被抢占的进程的上下文,然后将控制传递给这个新恢复的进程。
6-2 进程上下文切换的剖析
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
6.6 hello的异常与信号处理
在Hello运行的过程中会多次出现异常,通过linux系统的信号机制来使Hello正常运行。
首先,即使是Hello正常运行的时候,也会出现异常控制流。比如说,所有的系统都有某种周期性定时器中断的机制,通常为1毫秒或每10毫秒,当每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,这时就会调度运行另一个进程,而将当前Hello进程搁置。
事实上的linux系统时相当复杂和繁忙的,即使开了电脑后什么也不做,系统也在后台不断地运行着几千个进程。通过调度器的调度使这些进程井然有序的使用cpu资源。
我们需要重点讨论的是Hello函数本身的异常。
6-3 hello程序源代码
Hello程序的main函数如上图,其中的sleep函数就会像进程本身发送一个STPSIG使其休眠一段时间。在程序中,这个时间是2.5秒。
当请求的时间到了,或者sleep函数被一个信号中断,进程就会继续执行,继续调用printf函数。
当程序正常执行直到结束时,显示如下:
6-4
如果在程序运行到中途时按下ctrl+z,产生情况如下:
6-5
由于键盘输入的ctrl+z给程序传入了一个SIGSTP信号,这个信号使程序暂时挂起。此时可以输入ps命令查看进程。
6-6
可以看到,此时hello-ld程序仍然在后台进程当中而没有中止。
此时如果继续输入fg,就能使hello-ld程序继续执行。如下图
6-7
如果在程序运行的时候键入ctrl+c,就会给进程发送一个终止信号。如下图
6-8
可以看到,此时hello-ld已经不在作业列表当中了。
如果在程序执行时乱按键盘,程序仍然会正常执行:
6-9
在程序执行到一半的时候将其停止,输入pstree,能够看到当前计算机正在执行的所有进程的关系:
6-10
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.7本章小结
有了跑车还不算完成任务,因为如何驾驶跑车也是一个大问题,就算是老司机也难免翻车,进程管理就是为了约束程序的运行而存在的。
程序从加载的内存中开始就独自享有一份上下文,在自己的进程里*的运行。但是为了能够有效的管理进程,系统中有称为异常的机制,能够改变控制流,使程序在自己的进程出现问题时不会束手无策,而是获得来自外部的帮助。同样的,不同进程之间需要沟通,信号就是为此而存在的。信号时管理程序运行的一大利器。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
机器语言指令中出现的内存地址,都是逻辑地址,需要转换成线性地址,再经过MMU(CPU中的内存管理单元)转换成物理地址才能够被访问到。
逻辑地址:包含在机器语言中用来指定一个操作数或一条指令的地址。每一个逻辑地址都由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离。
通俗的说:逻辑地址是给程序员设定的,底层代码是分段式的,代码段、数据段、每个段最开始的位置为段基址,放在如CS、DS这样的段寄存器中,再加上偏移,这样构成一个完整的地址。
Linux中逻辑地址等于线性地址。为什么这么说呢?因为Linux所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从 0x00000000 开始,长度4G,这样 线性地址=逻辑地址+ 0x00000000,也就是说逻辑地址等于线性地址了。
虚拟地址将贮存看成是一个存储在磁盘上的地址空间的高速缓存,再主存中只保存活动区域,并根据需要再磁盘和主存之间来回传送数据,通过这种方式,它高效的使用了主存。同时,它为每个进程提供了一致的地址空间,从而简化了内存管理。最后,它保护了每个进程的地址空间不被其他进程破坏。
而物理地址则是对应于主存的真实地址,是能够用来直接在主存上进行寻址的地址。由于在系统运行时,主存被不同的进程不断使用,分区情况很复杂,所以如果要用物理地址直接访问的话,地址的处理会相当麻烦。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在多段模式下,每个程序都有自己的局部段描述符表,而每个段都有独立的地址空间
在80386 的段机制中,逻辑地址由两部分组成,即段部分(选择符)及偏移部分。
段是形成逻辑地址到线性地址转换的基础。如果我们把段看成一个对象的话,那么对它的描述如下。
(1)段的基地址(Base Address):在线性地址空间中段的起始地址。
(2)段的界限(Limit):表示在逻辑地址中,段内可以使用的最大偏移量。
(3)段的属性(Attribute): 表示段的特性。例如,该段是否可被读出或写入,或者该段是否作为一个程序来执行,以及段的特权级等。
7-1
7.3 Hello的线性地址到物理地址的变换-页式管理
分页管理是地址翻译的一个基本思路。
概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块,这些块作为自盘和主存(较高层)之间的传输单元。VM系统通过将虚拟内存分割为成为虚拟页的大小固定的块来处理这个问题,对这些虚拟页的管理与调度就是页式管理。
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的那个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM中,替换这个牺牲页。
7.4 TLB与四级页表支持下的VA到PA的变换
首先讨论单级页表下的VA到PA的变换。
当一个进程执行一条访存指令时,它发出的内存地址是虚拟地址,由内存管理单元(Memory Management Unit MMU)将虚拟地址转化为物理地址,并访问主存,取出所要读取的数据。
页表的地址映射规则如下:
7-2 使用页表的地址翻译
在这个过程中,cpu硬件将会执行以下步骤:
- 处理器生成一个虚拟地址,并把它传送给MMU
- MMU生成PTE地址,并从告诉缓存/主存请求得到它
- 高速缓存/主存向MMU返回PTE
- MMU构造物理地址,并把它传送给高速缓存/主存
- 高速缓存/主存发挥所请求的数据字给处理器
7-3 页命中和缺页的操作图
7.5 三级Cache支持下的物理内存访问
不同存储技术的访问时间差异很大,速度较快的计数每字节的成本要比速度较慢的计数高,而且容量较小。计算的另一个特点就是局部性,即计算机程序倾向于访问最近访问过的某一块程序。存储器的这些基本属性相互补充使得计算机可以通过采用构建存储器层次结构来提升运行效率。
三级Cache的核心思想就是每次访问数据的时候都将一个数据块存放到更高一层的存储器中,根据计算的局部性,程序在后面的运行之中有很大的概率再次访问这些数据,高速缓存器就能够提高读取数据的速度。
7-4
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用的时候,内核会为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进场创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本,它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记位私有的写时复制。
7-5 一个私有的写时复制对象
虚拟内存的机制使得fork函数可以快速的运行,因为当我们fork了一个新进程的时候,系统事实上并没有将原进程的整个上下文复制一遍,它仅仅只是创建了份一模一样的描述地址空间的数据结构,然后将这个数据结构给予子进程。当子进程执行只读代码时,它与父进程实际上共用了物理内存中的同一片区域的内容。
当fork在新进程中返回时,新进场现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建信也米娜。因此,通过虚拟内存这种巧妙的机制为每个进程都保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
同理,在虚拟内存的机制下,execve也可以简单快速的实现。
通过execve函数在当前进程中加载并运行包含之可执行目标文件中的程序,用a.out程序有效地替代了当前程序。这个过程有以下几个步骤:
- 删除已存在的用户区域
删除当前进程虚拟地址的用户部分中已存在的区域结构。
- 映射私有区域
为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的,写时复制的。代码和数据区域被映射为a.out文件中的.test和.data区。bss区域是请求二进制0的,映射到匿名文件,其大小包含在a.out当中。栈和堆区域也是请求二进制零的,初始长度为零。下图概括了私有区域的不同映射。
7-6 加载器是如何映射用户地址空间区域
- 映射共享区域
如果a.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器
execve做的最后一件事情就是设置当前进程上下文中的程序计数器,食指指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的那个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM中,替换这个牺牲页。
当CPU想要读取虚拟内存中的某个数据,而这一片数据恰好存放在主存当中时,就称为页命中。相对的,如果DRAM缓存不命中,则称之为缺页。如果CPU尝试读取一片内存而这片内存并没有缓存在主存当中时,就会触发一个缺页异常,这个异常的类型是故障。此时控制流转到内核中,由内核来尝试解决这个问题。
7-7 触发缺页
缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,然后用磁盘中将要读取的页来替代牺牲页。处理程序解决了这个故障,将控制流转移会原先触发缺页故障的指令,当cpu再次执行这条指令时,对应的页已经缓存到主存当中了。这就是缺页故障与缺页中断的处理。
7-8 解决缺页异常
7.9动态存储分配管理
动态内存分配器通过维护一个存放着堆的分配情况的数据结构来实现动态的内存分配。
mem_init函数将对于堆来说可用的虚拟内存模型化为一个大的,双字对齐的字节数组。在mem_heap和mem_brk之间的字节表示已分配的虚拟内存。mem_brk之后的字节表示未分配的虚拟内存。分配器通过调用mem_sbrk函数来请求额外的堆内存。
分配器需要满足下列要求
处理任意请求序列
立即响应请求:分配器必须立即响应请求。因此,不允许分配器为了提高性能重行排列或者缓冲请求。
只使用堆:为了使分配器可以拓展,分配器使用的任何非标量数据结构都要保存到堆里。
对齐块:使得其可以保存任何类型的数据对象。
不修改已经分配的块。
- 带边界标签的隐式空闲链表分配器原理
隐式空闲链表分配中,内存块的基本结构如下:
7-9 使用边界标记的堆块的格式
其中头部和脚部分别存放了当前内存块的大小与是否已分配的信息。
通过这种结构,隐式动态内存分配器会对堆进行扫描,通过上图中的头部和脚部的结构来实现查找。
- 显式空间链表的基本原理
显式空间链表的一种实现的基本结构如下:
7-10 空闲块
将一个空闲内存块的有效载荷利用起来,存放着指向下一个以及上一个空闲块的指针。
通过这种结构可以实现将内存块以不按顺序的形式组织成合适的结构,比如说递增序列。通常会在初始化堆的时候额外开辟一块对空间,用于存放用来维护链表的数据结构。
7.10本章小结
马路上不可能只有一辆车,因此车行的先后,车辆的避让需要一套规则来管理。因此开车光是掌握了车辆的驾驶技术还不行,还需要交通规则。交通规则可以类比为计算机中的虚拟内存机制,管理着存储资源的调度。
为了更加有效地管理内存并且少出错,现代系统提供了一种对主存的抽象概念,叫做虚拟内存(VM)。虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的,一致的和私有的地址空间。通过一个很清晰的机制,虚拟内存提供了三个重要的能力:
- 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,高效使用了主存
- 它为每个进程提供了一致的地址空间,从而简化了内存管理
- 它保护了每个进程的地址空间不被其它进程破坏
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m个字节的序列,所有的I/O设备(例如网络,磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单的、低级的应用皆可,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
- Linux创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
- 改变当前的文件位置。对于每个打开的文件内核保持着一个文件位置k,初始为0.这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k
- 读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会出发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处没有明确的“EOF符号”。
类似的,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
unix io 函数(需要包含头文件 <sys/types.h><sys/stat.h><fcntl.h>):
- int open(char *filename, int flags, mode_t mode);
open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件;
- O_RDONLY
只读
- O_WRONLY
只写
- O_RDWR
可读可写
- O_CREAT
如果文件不存在,就创建它的一个截断的文件
- O_TRUNC
如果文件已存在,就截断它
- O_APPEND
在每次写操作前,设置文件位置到文件的结尾处
- int close(int fd);
关闭一个打开的文件
- ssize_t read(int fd, const void *buf, size_t n);
从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示实际传送的字节数量
- ssize_t write(int fd, const void *buf, size_t n);
从内存位置buf复制最多n个字节到描述符fd的当前文件位置。
8.3 printf的实现分析
谈起printf的具体实现,首先看看printf函数的函数体:
8-1 printf函数体
注意到函数体的参数列表里面有一个”…”,这个符号表达的意思是参数的个数不确定。那么printf函数所要做的第一件事,就是确认函数的参数到底有多少。
注意到函数体中有这样一条定义:
而由函数的栈帧结构:
8-2 函数的栈帧结构
可以推断出,arg指针指向了传递给printf的第一个参数的地址。
接下来函数调用了vsprintf函数,其函数体如下:
8-3 vsprintf函数体
阅读函数体可以知道,这个函数的作用就是格式化。它接受确定输出格式的格式字符串fmt,用格式字符串堆个数变化的参数进行格式化,产生格式化输出。
接下来,printf函数会调用系统io函数:write
write是一个系统函数,其作用就是从内存buf位置复制最多i个字节到一个文件位置。而在linux系统中,系统IO被抽象为文件,包括屏幕。对于系统来说,我们的显示屏也是一个文件,我们只需要将数据传送到显示屏对应的文件,就已经完成了系统端的任务,余下的工作独立的由显示器来进行了。
于是在这里,write会给寄存器传递几个参数,初始化执行环境,然后执行sys call指令,这条指令的作用是产生陷阱异常。
陷阱是有意的异常,用户程序执行了系统调用的命令(syscall)之后,就导致了一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。
8-4 陷阱处理
需要注意,这里的系统调用试运行在内核模式中的。
接下来,系统已经确定了所要显示在屏幕上的符号。根据每个符号所对应的ascii码,系统会从字模库中提取出每个符号的vram信息。
显卡使用的内存分为两部分,一部分是显卡自带的显存称为VRAM内存,另外一部分是系统主存称为GTT内存(graphics translation table和后面的GART含义相同,都是指显卡的页表,GTT 内存可以就理解为需要建立GPU页表的显存)。在嵌入式系统或者集成显卡上,显卡通常是不自带显存的,而是完全使用系统内存。通常显卡上的显存访存速度数倍于系统内存,因而许多数据如果是放在显卡自带显存上,其速度将明显高于使用系统内存的情况(比如纹理,OpenGL中分普通纹理和常驻纹理)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar 由宏实现:#define getchar() getc(stdin)。
getchar 有一个int型的返回值.当程序调用getchar时.程序就等着用户按键.用户输入的字符被存放在键盘缓冲区中.直到用户按回车为止(回车字符也放在缓 冲区中).当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符.getchar函数的返回值是用户输入的第一个字符的ASCII 码,如出错返回-1,且将用户输入的字符回显到屏幕.如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用 读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键.
getchar函数的功能是从键盘上输入一个字符。其一般形式为: getchar(); 通常把输入的字符赋予一个字符变量,构成赋值语句。
进入getchar之后,进程会进入阻塞状态,等待外界的输入。系统开始检测键盘的输入。此时如果按下一个键,就会产生一个异步中断,这个中断会使系统回到当前的getchar进程,然后根据按下的按键,转化成对应的ascii码,保存到系统的键盘缓冲区。
接下来,getchar调用了read函数。read函数会产生一个陷阱,通过系统调用,将键盘缓冲区中存储的刚刚按下的按键信息读到回车符,然后返回整个字符串。
接下来getchar会对这个字符串进行处理,只取其中第一个字符,将其余输入简单的丢弃,然后将字符作为返回值,并结束getchar的短暂一生。
8.5本章小结
IO是复杂的计算机内部与外部沟通的通道。尽管我们时时刻刻都在使用着IO:通过键盘输入,通过屏幕阅读。但是系统IO实现的细节同样也是相当复杂的。
本章介绍了linux系统下的IO的基本知识,讨论了IO在linux系统中的形式以及实现的模式。然后对printf和getchar两个函数的实现进行了深入的探究。
(第8章1分)
结论
首先通过键盘向计算机输入一串代码,这串代码组合成了一个hello.c源文件。
接下来将源文件通过gcc编译器预处理,编译,汇编,链接,最终完成一个可以加载到内存执行的可执行目标文件。
接下来通过shell输入文件名,shell通过fork创建一个新的进程,然后在子进程里通过execve函数将hello程序加载到内存。虚拟内存机制通过mmap为hello规划了一片空间,调度器为hello规划进程执行的时间片,使其能够与其他进程合理利用cpu与内存的资源。
然后,cpu一条一条的从hello的.text取指令执行,不断从.data段去除数据。异常处理程序监视着键盘的输入。hello里面的一条syscall系统调用语句使进程触发陷阱,内核接手了进程,然后执行write函数,将一串字符传递给屏幕io的映射文件。
文件对传入数据进行分析,读取vram,然后在屏幕上将字符显示出来。
最后程序运行结束,shell将进程回收,完成了hello程序执行的全过程
从键盘上敲出hello.c的源代码程序不过几分钟,从编译到运行,从敲下gcc到终端打印出hello信息,可能甚至不需要1秒钟。
这短短的1秒,汇集了计算机工作者们几十年的智慧与心血。
高低电平传递着信息,这些信息被复杂而严谨的机器逻辑捕捉。cpu不知疲倦的取指与执行。对于hello的实现细节,哪怕把这篇论文再扩充一倍仍讲不清楚。正因为如此,我意识到自己还有很长的路要走。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
Hello.c |
Hello的c源代码 |
Hello.i |
源代码预编译产生的ascii文件 |
Hello.s |
Ascii文件编译后产生的汇编代码文件 |
Hello.o |
汇编后产生的可重定位目标文件 |
Hello_o-objdump-d.txt |
可重定位目标文件的对应汇编代码 |
Hello_o-objdump-d-r.txt |
可重定位目标文件的代码及重定位条目 |
Hello_o-readelf-a.txt |
可重定位目标文件的elf条目 |
Hello-ld |
链接生成的可执行目标文件 |
Hello-ld-readelf-a.txt |
可执行目标文件对应的elf条目 |
Hello-objdump-d-r.txt |
可执行目标文件对应的汇编代码 |
Hello-linkinfo.txt |
可执行目标文件的链接信息 |
列出所有的中间产物的文件名,并予以说明起作用。
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1]Eteran, Evan. “Eteran/Edb-Debugger.” GitHub, 2018, github.com/eteran/edb-debugger/wiki/Data-View.
[2]flood, rain. “编译并连接从helloworld.c生成的汇编代码的方法步骤.” 为什么版本控制如此重要? - CSDN博客, 2017, blog.csdn.net/rainflood/article/details/75635447.
[3]stx, piani. “Pianistx.” 数字故宫(360全景+纪录片+数据库+公开课) - Zeroassetsor - 博客园, 2014, www.cnblogs.com/pianist/p/3315801.html.
[4]toeic, clover. “clover_toeic.” 数字故宫(360全景+纪录片+数据库+公开课) - Zeroassetsor - 博客园, 2014, www.cnblogs.com/clover-toeic/p/3851102.html.
[5]Xu, Mike. “Linux中的逻辑地址,线性地址和物理地址转换关系.” 为什么版本控制如此重要? - CSDN博客, 2014, blog.csdn.net/u011253734/article/details/41173849.
(参考文献0分,缺失 -1分)