本文以一个简单程序hello从编写到执行,最后终止,被回收的全过程为例,详细介绍了如预处理,编译,汇编,链接,运行,访存等各个部分的具体状态,并详略得当地分析了各个过程当中计算机的软件和硬件如何有机地配合,共同为hello程序的生成和执行而努力,通过这样的方式,大致构建了计算机系统课的知识体系,回顾了使用的各种工具和方法,涉及的计算机的各种结构设计和数据结构,最终总结出了有关hello运行的总体性过程。
关键词:操作系统;内存;编译;汇编;进程;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 5 -
5.3 可执行目标文件hello的格式....................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 10 -
6.3 Hello的fork进程创建过程........................................................................ - 10 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................... - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理.......................................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................ - 11 -
7.5 三级Cache支持下的物理内存访问............................................................. - 11 -
7.6 hello进程fork时的内存映射..................................................................... - 11 -
7.7 hello进程execve时的内存映射................................................................. - 11 -
7.8 缺页故障与缺页中断处理.............................................................................. - 11 -
8.2 简述Unix IO接口及其函数.......................................................................... - 13 -
第1章 概述
1.1 Hello简介
P2P的过程:
预处理: 预处理器(cpp)根据字符#开头的命令,修改原始的程序员用键盘输入的.c后缀的c程序(Program),并把它直接插入程序文本当中,得到了另一个以.i作为文件拓展名的c程序。
编译: 编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,其包含了一个汇编语言程序,该程序还包含main函数的定义。
汇编: 汇编器(as)将hello.s翻译成积极语言指令,并且把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目文件hello.o中,其是一个二进制文件。
链接: 链接器负责处理如printf等存在于其他.o目标文件中的文件,将他们与main.o文件合并在一起,修改重定向条目,得到一个hello文件,它是一个可执行文件,可以被加载到内存之中,由系统进行执行。
由shell运行: 在得到可执行目标文件hello之后,其被存放在磁盘上,我们可以将其文件名输入到shell中,shell将通过fork的方式,生成一个新的子进程(Process),并且在子进程的上下文中加载并运行这个程序文件。
O2O的过程:
程序员用键盘输入得到了一个hello.c文件,经过了预处理,编译,汇编,链接,最终形成了一个可执行文件hello。程序员在shell中运行./hello程序,shell通过调用fork函数,在生成的子进程的上下文中调用execve运行这个程序hello,在hello的运行过程中,cpu为其分配内存,时间片,使其看似“独享”cpu资源,MMU为其创造了一个看似独立的虚拟空间,完成了虚拟地址到物理地址的转化,TLB和页表为地址翻译变化的过程提供了贡献,而CPU又使用cache加速了内存的访问,再加上硬件中总线,IO设备等的通力合作,一串字符串显示在屏幕上,程序在运行完毕后退出,变成一个僵死进程,其将会发送信号给他的父进程,父进程将其回收,hello就完成了它的使命。
1.2 环境与工具
Intel Core i7 x642.7Ghz cpu, 8GB RAM
Ubuntu 18.04 LTS操作系统, gcc, emacs,readelf,objdump
1.3 中间结果
中间文件名 | 作用 |
Hello.i | 预处理器产生的文件,根据其中#开头的命令修改原来的程序 |
Hello.s | 编译器产生的文件,包含一个汇编语言程序 |
Hello.o | 汇编器产生的一个二进制文件,包含了一些重定位条目和机器语言指令以及数据段,符号表等 |
Hello | 由链接器产生的可执行目标文件,链接器修改了重定位条目,将多个.o文件链接在一起生成了一个可以被加载到内存并执行的文件 |
Hello_elf | Hello的elf格式文件 |
Hello_o_elf | Hello.o的elf格式文件 |
Hello_objdump | Hello的反汇编文件 |
Hello_o_objdump | Hello.o的反汇编文件,带有重定位条目标注信息 |
1.4 本章小结
本章以hello.c为例,概述了一个由程序员编写的程序如何从.c文件生成可执行目标文件,在硬件和软件的通力合作下被运行,终止,最后被回收的由产生到结束的过程。并且列出了所需要的运行环境和工具,以及产生的中间文件名和作用。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预编译程序从程序文件读出源代码,并且对其中内嵌的指示字进行响应,产生源代码的修改版本,其主要处理那些源代码文件中以字符#开头的预编译指令,比如#include, #define[1]。预编译产生的是.i文件。
作用: 其能够将所有的#define删除,并展开所有的宏定义;处理所有的条件预编译指令,比如#if, #ifdef, #elif等;处理#include预编译指令,将被包含的文件插入到该预编译指令所在的位置,这个过程是递归进行的;删除所有的注释;添加行号和文件名标识,以便于编译时编译器产生条实用的行号与代码段的映射信息;保留所有的#pragma编译器指令。当我们无法确定宏定义是否正确,或者包含的头文件是否正确的时候,可以查看预编译后的文件来定位问题所在。
2.2在Ubuntu下预处理的命令
命令:gcc -E -o hello.i hello.c
图2.1 预处理命令
2.3 Hello的预处理结果解析
Hello.c经过预处理之后得到hello.i文件。Hello.i文件也是一个文本文件,我们能够使用emacs直接打开,程序经过预处理之后一共有3118行,通过观察可以发现被拓展的内容都是#include命令需要插入的文件中的代码。
图2.2 部分hello.i文件的内容
打开.i文件,如图2.2所示,发现其中包含了大量有关.h文件所在位置的行,查看gnu的相关文档[2](如图2.3所示),知道这样的一条语句被视为相应的”行指令”,行指令主要由linenum, filename和flag构成。其可以帮助调试器显示行号
图2.3 GNU的预处理有关文档
以行命令# 1 "/usr/include/stdio.h" 1 3 4为例,最前面的1就是linenum,这是一个非负十进制常量,其确定了应当从文件的第几行开始读取,中间的则是需要读取的文件路径filename,后面的三个数字是flag, 数字1标识新文件的开始,2标识返回到文件(说明.h文件还嵌套有别的.h文件),3表示以下文本来自系统头文件,因此应当禁止某些警告,4标识应当将以下文本视为包含在隐式的ectern”c”块当中。有多个数字时,将会根据升序输出。
除了大量对头文件的引用之外,其中还包括了大量结构体和联合、枚举类型的定义,以及一些声明定义的typedef和一些全局变量的定义以及函数的定义,如图2.4所示。
图2.4 hello.i文件中的其他内容
直到文件的最后,才是我们编写的函数的主要部分:
图2.5 hello.c的主要部分
总的来说,预处理程序将宏定义和引用展开,并且加入了一些标识符供后面的程序标识和辨认。
2.4 本章小结
本章内容主要介绍了预处理的概念以及预处理的作用,同时对hello.i文件中的内容进行了一定的解释和分析,比如说一条“行指令”每部分的作用等。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:编译就是编译器(cc1)利用预处理过后的文件,进行一系列词法分析、语法分析、语义分析及优化后生成一个相应的.s格式的汇编代码文件,这个过程是整个程序构建的核心部分,也是最复杂的部分之一。
作用:
1. 词法分析:源代码程序被输入到扫描器(Scanner),扫描器对源代码进行简单的词法分析,运用类似于有限状态机(Finite State Machine)的算法可以很轻松的将源代码字符序列分割成一系列的记号(Token)。在识别记号的同时,扫描器也完成了其他工作,比如将标识符存放到符号表,将数字、字符串常量存放到文字表等。语法分析:如lex工具或者flex工具之类的扫描器能够将输入的模式转换成状态图,并生成相应的实现代码并存放到特定的文件中,这些代码模拟了状态转换图。
2.语法分析:语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree)。整个分析过程采用了上下文无关语法(Context-free Grammar)的分析手段。由语法分析器生成的语法树就是以表达式(Expression)为节点的树。
3.语义分析:由对应的语义分析器完成,指示判断是否合法。
4.优化:在目标代码的生成阶段,编译器会选择合适的寻址方式,比如用左移或者右移来代替乘除,用条件传送指令来代替条件跳转等等优化。
3.2 在Ubuntu下编译的命令
命令:gcc -S -o hello.s hello.c
图3.1 ubuntu下的编译命令
3.3 Hello的编译结果解析
3.3.1 全局变量
在hello.c的开头,定义了一个全局变量sleepsecs,且初始化了该变量为2.5,如图3.2所示:
图3.2 定义的全局变量
而经过编译器处理后,从图3.3中我们可以看出,编译器先声明了这个全局变量的符号名,类型和大小,从文件中我们可以看出,sleepsecs被存放在.data节当中,且被初始化为long型的2,意味着在代码中写的2.5被强制类型转换成了long型,且砍去了小数部分,被存储为了2。
图3.3 sleepsecs的定义
3.3.2 局部变量
1. 整形
如图3.4所示,在main中一共定义了两个局部整形变量:
图3.4 局部整形变量
argc在程序中的一个if语句中被使用,而i主要被当做for循环的循环参数。查看图3.5中的汇编代码
图3.5 部分汇编代码
从其中的movl %edi, %-20(rbp), 和cmpl $3, -20(%rbp)语句我们可以看出,argc的二进制标识被当做一个32位的数据,由%edi传送到了栈当中,由栈进行存储。而对于i,从图3.6的addl $1, -4(%rbp)我们也可以看出, i的数值也是由栈来进行存储的。
图3.6 与i有关的汇编代码
- 指针数组&数组和指针操作
Main函数中有一个char*型的指针数组argv[],其在汇编代码中的有关代码如图3.7所示:
图3.7 与argv[]有关的汇编代码
从以上汇编代码可以看出,局部的指针数组也是被存储在栈当中,并且存储的数据就是地址,当用户输入有关的参数之后,这些参数就被存储在对应的地址里面,此时栈中存储了指向该字符串的地址,通过调用Printf函数,其能够以该地址为起始读取字符串并输出。
- 字符串
Main中有一个直接打印出来的字符串printf("Usage: Hello 学号 姓名!\n"); 通过查看我们可以发现,该字符串在.LC0当中,而.LC0保存在.rodata节里面。直接以ascii码和UTF-8码的形式保存在.rodata节当中。
图3.8 局部字符串类型
3.3.3 赋值
程序中的赋值操作有对sleepsecs的初始化,和对i的改变,从汇编代码中可以看出,赋值或者通过汇编语句mov来完成。或者通过.data表来存储。
3.3.4 类型转换
此处的类型转换只有一个隐式的类型转换,就是将int型的sleepsecs赋值为2.5,而从图3.3中我们得知,编译器直接将2.5改成了2,完成了隐式的数据类型转换。
3.3.5 算数操作
代码中的算数操作有i++,从图3.9的汇编代码可以读出,此处的i总是自增1,i存储在栈当中,编译器产生了addl $1, -4(%rsp)来实现i的自增。
图3.9 算数操作
3.3.6 关系操作
代码中的关系操作有argc != 3和 i < 10两个,汇编器针对这样的比较,先使用了cmpl语句,cmpl语句会执行sub的操作,但是不会改变寄存器的值,其相当于将变量和立即数进行比较,并设置对应的条件码,在通过je 或者jle进行条件跳转,实现了比较的失败或者成功的跳转,如图3.10所示。
图3.10 关系操作
3.3.7 数组/指针/结构操作
Hello.c中用到了一次数组操作,调用了argv[1]和argv[2], 数组操作的本质是在数组首地址的基础上加上对应的偏移量进行访问,所以对于数组的操作就相当于对指针的操作,是从带偏移的地址中取值的过程。如图3.11所示:
图3.11 数组操作
3.3.8 控制转移
代码中的控制转移语句主要是if语句,如图3.10, 汇编代码通过cmpl语句实现if的比较并且设置对应的条件码。在设置完条件码之后,通过je或者jle实现跳转到不同的代码段。主要的跳转指令如图3.12所示:
图3.12 主要的跳转指令
3.3.9 函数操作
函数的参数传递主要通过寄存器来完成,常用的传参计算器有%rdi, %rsi, %rdx, %rcx 等,其分别为第一、第二、第三和第四个参数,调用函数前,将对应的参数存入这些寄存器当中,被调用的函数就可以通过访问这些寄存器来访问参数。而函数的调用通过call指令来完成。Call指令将使栈内存储的数据发生变化,%rip的值将被推入栈当中,
用作返回时的值,当被调用函数返回时,栈中存储的原%rip的值将会被恢复到%rip寄存器当中,此时cpu又将从调用函数语句call的下一条语句开始继续执行。
在本章中总共调用了五个函数:
- Main函数
Main函数的参数有argc和argv[]两个,由命令行输入,分别存储在 %rdi和%rsi当中。
- Puts函数
Printf函数可能被优化为puts函数,函数的参数保存在了%rdi当中。
图3.13 puts函数
- Printf函数
在本程序中,Printf函数需要三个参数,分别是LC1.,argv[1], 和argv[2],其分别保存在%rdi, %rsi, %rdx当中。
图3.14 printf函数
- Sleep函数
Sleep函数需要一个参数,用来指定一个进程被挂起的时长,这里时长就是我们设置的全局量sleepsecs, 其被保存在%rdi当中。
图3.15 sleep函数
- Getchar函数
Getchar函数不需要任何参数,调用getchar()函数之后,需要用户通过键盘发送一个信号,才能够结束挂起。
3.4 本章小结
本章主要结合hello.s文件,介绍了编译器如何处理C语言的预处理文件中的各种数据类型的运算和传递和各种操作在汇编代码中是如何实现的,以及汇编代码中的各个部分起到了什么作用,函数的参数如何被传递,数据类型如何被解释等。编译器(cc1)利用预处理过后的文件,进行一系列词法分析、语法分析、语义分析及优化后生成一个相应的.s格式的汇编代码文件,这个过程是整个程序构建的核心部分,而经过该部分操作生成的汇编代码文件依然不能够被运行,还需要经过汇编和链接的过程。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器(as)将.s文件(例如hello.s)文件翻译成机器语言指令,并且把这些指令打包在一种叫做可重定位目标程序的格式,并将结果保存在后缀为.o的(如hello.o)中,Hello.o文件是一个二进制文件。
作用:将汇编器产生的汇编语言进一步翻译成计算机可以理解的机器语言指令,生成一些重定位条目和各种各样的的节,保存在.o文件当中。
4.2 在Ubuntu下汇编的命令
命令:gcc -c -o hello.o hello.s
图4.1 ubuntu下的汇编的命令
4.3 可重定位目标elf格式
一个典型的elf格式的可重定位目标文件的结构应当如图4.2所示。
图4.2 典型elf可执行目标文件
用readelf -a hello.o命令列出其各节的信息,分别进行分析。
首先查看elf头的文件信息如图4.3所示,hello.o的elf头的大小为64个字节,elf头的第一行对应的是elf的固定开头7f454c46,其中0x45, 0x4c, 0x46代表的分别是字符’e’, ‘l’, ‘f’的ascii码,以表示这是一个elf对象。接下来的02, 01, 01分别标识该文件是一个64位对象,由小端法表示,以及当前的头文件版本。剩下的位默认都设置为0。Elf头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。如elf头的大小为64个字节,目标文件的类型---可重定位文件,机器类型---x86-64,节头部表的偏移量,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表来描述的。
图4.3 elf头的信息
图4.4展示了一个节头部表的内容。Hello.o一个包含了12个节,节头部表中介绍了这些节的名称和大小以及偏移量等内容。
图4.4 节头部表
接下来就是.rela.text,也就是代码中包含的重定位条目,如图4.5所示,其描述了重定位条目的类型,和需要重定位的条目的位置,以及有关的信息和符号的名称。其中的偏移量指的是需要重定位的机器码的首地址。而信息则包括了重定位条目的相关信息。一个重定位条目的信息常常包括其在符号表中的相对位置,类型,符号表的下标等信息。以图4.5的第二个条目为例,偏移量为0x1d,则从图4.6可以看出其提示链接器要从代码段偏移量为0x18的位置开始修改机器代码。
图4.5 代码中包含的重定位条目
图4.6 重定位条目在代码中的位置
而其重定位条目中的信息为000c00000004,0x0c代表的是其在符号表中的序号,即第12个,如图4.7所示。
图4.7 符号表中的相关信息
而0x04表示的是该重定位条目的类型,该处为R_X86_64_PLT32型。重定位条目主要用于生成位置无关的代码。书中介绍的PC相对寻址的重定位算法先计算出引用的运行时地址,即需要修改的机器码的首地址:refaddr = ADDR(s) + r.offset,即将重定位条目中的偏移量加上所在节的偏移量,即可得到首地址。然后更新该引用,使其能够在运行时指向需要的符号所在的位置:*refptr = (unsigned) (ADDR(r.symbol) + r.adddend - refaddr),即将符号所在的地址,减去引用时的运行时地址,再加上当前指令pc的长度,就能够计算出相对的偏移量,得到正确的pc相对引用的地址,修改重定位条目的工作主要由链接器来完成。
最后则是.symtab节,其内容如图4.8所示。.symtab节中包含的内容就是在.o文件中被引用或定义的所有符号的名字,以及其偏移量,大小,是否为全局变量以及类型的信息。Num指的是编号,value则是符号的偏移地址,对于可重定位模块来说,value是距离定义符号所在的节的起始位置的偏移地址。而对于可执行目标文件来说,value中保存的是绝对的运行时地址。在NDX中,ABS代表不该被应用的符号,UND代表未被定义的符号,也就是在本模块中被应用,但是在其他地方被定义。
图4.8 .symtab节的内容
4.4 Hello.o的结果解析
利用 objdump -dx hello.o,能够反汇编hello.o代码,并且将重定位的信息和汇编代码以及对应的机器码合并显示出来,将其与hello.s进行对比,如图4.9所示。
可以看出,机器语言是cpu能够直接理解并执行的语言,其完全由二进制数构成,但在此其为了方便查看被转化成十六进制数表示出来。机器码主要由操作码和操作数组成,一个字节是能够表示一个操作码的最小单位。
图4.9 反汇编hello.o并与hello.s对照
机器语言与汇编语言的映射关系:
- 由汇编代码和其经过汇编之后得到机器码反汇编出来的汇编代码的操作大致是相同的。但是可以发现.o文件反汇编得到的汇编代码中的对栈内存的访问和原来的汇编代码中的不同。如图4.10两个部分的汇编代码中,对栈中内容的应用所选择的地址就有一些差别。经过对比我们可以发现,0x14正好是十进制数20的16进制表示,事实上,由编译器生成的汇编代码中,立即数和地址偏移都是用十进制直接标识的,而在汇编器生成的机器码中,其都被转化成了二进制表示,objdump在反汇编的过程中,也直接将这些数字用十六进制进行标识,因此造成了不同。
图4.10
- 从中我们还可以看出,分支转移函数的操作数其实也有一些差别。如图4.11所示,在原始的汇编代码中,je跳转的操作数是一个段的名字,如.L2,而从hello.o对应位置的机器码却可以看出,跳转的操作数是16,而.L2对应的机器码在main中的偏移位置正好是0x15 + 0x16 = 0x21,因此我们可以发现,条件跳转的操作数在经过汇编之后,将会以pc相对寻址的方式被修改。
图4.11 分支跳转的比较
- 对于函数的调用和带有偏移量的内存取值,与原来的汇编代码比较也稍有不同。如图4.12所示。在编译器生成的汇编代码当中,leaq 的操作数的地址偏移量直接写上了.LC0的段的符号名,而从前面的章节我们知道.LC0保存在.rodata节中。汇编器为其在.text节中生成了一个重定位条目,lea的操作数都是00 00 00 00,就是在等待链接器根据重定位条目对其进行修改,修改成对应的格式。而函数调用也是相同的操作,其也为call指令生成了重定位条目,在链接的过程中,这些未被填写的操作数将会被进一步修改,方便访问。比如图4.12中,call指令即将调用puts,其在.s文件中直接被描述为puts@plt, 但是在经过汇编之后,其生成了一个重定位条目,方便生成准确的运行时地址。
图4.12 函数调用和带有偏移量的从内存中取值的对比
4.5 本章小结
本章我们主要对hello.o文件进行了分析,利用的工具主要有readelf 和 objdump工具,首先对一个可重定位目标文件中包含的各个节的内容以及含义进行了详细的分析,其后利用objdump生成的反汇编代码和hello.s中生成的汇编指令进行比较,分析了机器语言和汇编语言之间的映射关系,并分析其中的差别和原因,也对重定位条目的内容进行了重点分析。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合称为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行与加载时,也就是程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
作用:链接器能够将在不同的文件中寻找被引用的符号的定义;并以一定的规则进行选择并使用;链接器修改重定位条目,将其修改成真正的运行时相对或绝对地址;链接使得分离编译成为可能,能够更好地构造大型程序,对版本进行更新;链接器产生的可执行目标文件在重要的系统功能中扮演着关键角色,比如加载和运行程序、虚拟内存、分页、内存映射等;链接器还引入了共享库的概念,能够通过动态链接的方式来提供动态内容。
5.2 在Ubuntu下链接的命令
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
图5.1 Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
使用readelf -a hello 命令查看可执行目标文件的elf格式文件
图5.2 hello的节头目表
文件的elf头对所有的头部节信息进行了声明,其中包括每个节的大小和在代码段中的偏移量,以及节的名字等等信息,根据这些信息,我们就可以利用hexedit来定位各个值所在的区间,对其中的内容进行查看和修改。而地址项指的就是程序被载入到虚拟地址中的起始地址。
5.4 hello的虚拟地址空间
使用edb加载hello,在data dump窗口中可以看到虚拟地址空间中的各段信息。
查看从虚拟地址空间0x4000000 到 0x400fff存储的信息,如图5.3所示,可以发现虚拟地址开头处存储的就是elf头文件的内容,虚拟空间作为一个地址连续的空间,其中存储的内容实际上就是5.3节中各个节的信息。而节头部表则指出了其在虚拟空间中的起始位置,大小等信息。
图5.3 edb查看hello虚拟地址空间各段的信息
图5.2指出的.interp节的起始地址是0x400200,在edb中查看对应地址的内容,可以发现其存储的是linux有关共享库的路径。
图5.4 .interp节中的有关信息
从图5.2中可以得知.text节,即代码节的起始位置是0x400500,其中存储了所有的机器代码,但是由于代码多与ascii码并无直接关联,所以在右侧显示成乱码。
图5.5 .text节中存储的数据
查看0x400640处开始存储的.rodata节中存储的有关信息,可以看到在第三章观察过的输出字符串数据。
图5.6 .rodata中存储的数据
5.5 链接的重定位过程分析
由于我们在生成可执行目标文件hello的过程中,还链接了一些共享库,对比图4.4和图5.2中显示的两个节头部表的内容,我们可以发现hello中比hello.o多了一些节:
.interp:保存ld.so的路径
.note.ABI-tag:Linux下特有的节
.note.gnu.build-i:编译信息表
.gnu.hash:gnu的扩展符号hash表
.dynsym:动态符号表
.dynstr:动态符号表中的符号名称
.gnu.version:符号版本
.gnu.version_r:符号引用版本
.rela.dyn:动态重定位表
.rela.plt:.plt节的重定位条目
.init:程序初始化
.plt:动态链接表
.fini:程序终止时需要的执行的指令
.eh_frame:程序执行错误时的指令
.dynamic:存放被ld.so使用的动态链接信息
.got:存放程序中变量全局偏移量
.got.plt:存放程序中函数的全局偏移量
.data:初始化过的全局变量或者声明过的函数
分析hello反汇编的得到的代码与hello.o反汇编得到的代码可以发现链接器作出的一些操作:
- 链接器将多个文件中的函数合并在一起,我们在使用ld命令链接的时候,指定了动态链接器为64位的/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o。其中crtn.o中主要定义了程序的入口_start, 初始化函数_init等。_start程序调用了hello.c中的main函数。Libc.so是一个共享库,库中定义了hello.c使用到的一些库函数,如printf, sleep, getchar, exit函数等。
- 对于.text节中函数调用和条件跳转中所需要的地址根据重定位条目进行计算。书中介绍的PC相对寻址的重定位算法先计算出引用的运行时地址,即需要修改的机器码的首地址: refaddr = ADDR(s) + r.offset, 即将重定位条目中的偏移量加上所在节的偏移量,即可得到首地址。然后更新该引用,使其能够在运行时指向需要的符号所在的位置:*refptr = (unsigned) (ADDR(r.symbol) + r.adddend - refaddr),即将符号所在的地址,减去引用时的运行时地址,再加上当前指令pc的长度,就能够计算出相对的偏移量,得到正确的pc相对引用的地址。如图5.7所示,hello.o在该处显示的重定位条目的类型是R_X86_64_PC32,mov指令的操作数在hello.o中是00 00 00 00, 而在hello中ae 0a 20 00。
图5.7 修改有关的地址引用
- 链接的过程中函数还会修改关于.rodata节引用,修改的方式也与上文描述的类似。
图5.8 修改.rodata节的引用
5.6 hello的执行流程
使用edb执行hello,其调用程序的顺序和相应的程序地址如下所示:
程序名称 | 程序地址 |
ld-2.27.so!_dl_start | 0x7fce8cc38ea0 |
ld-2.27.so!_dl_init | 0x7fce8cc47630 |
hello!_start | 0x400500 |
libc-2.27.so!__libc_start_main | 0x7fce8c867ab0 |
-libc-2.27.so!__cxa_atexit | 0x7fce8c889430 |
-libc-2.27.so!__libc_csu_init | 0x4005c0 |
hello!_init | 0x400488 |
libc-2.27.so!_setjmp | 0x7fce8c884c10 |
-libc-2.27.so!_sigsetjmp | 0x7fce8c884b70 |
--libc-2.27.so!__sigjmp_save | 0x7fce8c884bd0 |
hello!main | 0x400532 |
hello!puts@plt | 0x4004b0 |
hello!exit@plt | 0x4004e0 |
*hello!printf@plt |
|
*hello!sleep@plt |
|
*hello!getchar@plt |
|
ld-2.27.so!_dl_runtime_resolve_xsave | 0x7fce8cc4e680 |
-ld-2.27.so!_dl_fixup | 0x7fce8cc46df0 |
--ld-2.27.so!_dl_lookup_symbol_x | 0x7fce8cc420b0 |
libc-2.27.so!exit | 0x7fce8c889128 |
5.7 Hello的动态链接分析
对于动态共享链接库中的函数,编译器没有办法预测函数的运行时地址,其重定位记录,将地址的修改交给动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表global offset table实现函数的动态链接,global offset table中存放函数目标地址,PLT使用global offset table中地址跳转到目标函数。在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。如在图5.4 (a)。
在dl_init调用之后,如图5.10,0x601008和0x601010处的两个8Byte数据分别改变为0x7f1d01c6b170和0x7f1d01a59750,如图5.10,其由小端序存储。其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址,如图5.11所示。GOT[2]指向动态链接器ld-linux.so运行时地址,如图5.12所示。
由图5.2可知.got.plt节的起始位置是0x600100。用edb查看其中的内容如下所示:
图5.9 .got.plt 节中的内容
图5.10 调用dl_init后.got.plt节中的内容
图5.11 GOT[1]指向的重定位表的内容
图5.12 ld.so动态链接器程序
5.8 本章小结
本章中我们主要介绍了有关链接器的概念和作用,介绍了链接器如何将多个文件整合在一起,如何根据重定位条目对可重定位目标文件中的内容进行修改。同时介绍了用edb观察各个函数运行和调用的过程,以及动态链接的过程等。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程的一个经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文当中。上下文是由程序正确运行所需的状态所组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用:
- 进程提供了一个独立的逻辑控制流的假象,好像我们的程序能够独占地使用处理器。
- 进程提供了一个私有的地址空间的假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用: shell是用户和Linux(Linux内核)之间的接口程序。用户在提示符下输入的每个命令都由shell先解释然后才传给Linux内核。shell 是一个命令语言解释器(command-language interpreter)。拥有自己内建的 shell 命令集。此外,shell也能被系统中其他有效的Linux 实用程序和应用程序(utilities and application programs)所调用。Shell能够分析键入的命令是否有效,确认有效后,如果是一个内置命令,shell将立即解释该命令,否则就试图创建一个新的子进程,并在子进程中运行命令中指定的程序[3]。不论何时你键入一个命令,它都被Linux shell所解释。一些命令,比如打印当前工作目录命令(pwd),是包含在Linux bash内部的(就象DOS的内部命令)。其他命令,比如拷贝命令(cp)和移动命令(rm),是存在于文件系统中某个目录下的单独的程序。而对用户来说,你不知道(或者可能不关心)一个命令是建立在shell内部还是一个单独的程序。
处理流程: shell 首先检查命令是否是内部命令,不是的话再检查是否是一个应用程序,这里的应用程序可以是Linux本身的实用程序,比如ls 和 rm,也可以是购买的商业程序,比如 xv,或者是公用软件(public domain software),就象 ghostview。然后shell试着在搜索路径($PATH)里寻找这些应用程序。搜索路径是一个能找到可执行程序的目录列表。如果你键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。而如果命令被成功的找到的话,shell的内部命令或应用程序将被分解为系统调用并传给Linux内核。其能够调用fork命令生成一个新的子进程,并在该子进程的上下文中运行用户所指定的程序。
6.3 Hello的fork进程创建过程
在终端内输入./hello 1170300206 zhongweihong,终端读取该命令行,并立即进行分析,其发现./hello 不是一个内置的shell命令,故应当是一个指定文件路径下的可执行目标文件,在此文件路径就是当前的路径。此时终端将调用fork函数,创建一个新的子进程,该子进程与父进程shell拥有相同的上下文,故./hello后输入的学号和姓名也被当做argv[]继承给了子进程。子进程继承了父进程已经打开的所有文件,而其与父进程之间最大的区别就在于它们拥有不同的PID。而在此时,由于该程序不会在后台运行,父进程将会调用waitpid,并显式地等待子进程结束而将其回收。简单的进程图如图6.1所示:
图6.1 hello子进程fork进程图
6.4 Hello的execve过程
通过fork生成子进程之后,子进程将会调用execve函数,execve函数将子进程的命令行参数继承,在当前进程的上下文中加载并运行一个新的程序,即hello程序,其通过调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序。其能够删除子进程现有的虚拟内存中的内容,并将hello程序运行所需的代码,数据,用户栈和堆添加进去,其通过虚拟内存的映射,将这些空间初始化为可执行文件中的内容,最后加载器设置PC指向_start地址,程序就开始从此运行,并且不会返回到原来的子进程的代码当中。此时虚拟内存映像的组织结构如下所示:
图6.2 虚拟内存映像的组织结构
6.5 Hello的进程执行
Hello进程在刚刚开始执行时,运行在用户模式当中,用户模式的进程不被允执行特权指令,也不被允许直接引用地址空间中内核区内的代码和数据。除了由于代码造成的挂起,内核还可能会决定抢占当前的进程,并且重新开始一个先前被抢占了的进程,这种决策称为调度。其使用一种上下文切换的机制来将控制转移到新的进程。此时hello进程的上下文将会被保存,被恢复的进程的保存的上下文也将会被恢复,在发生调度的时候,通过执行系统调用read, 模式将会从用户模式短暂地陷入到内核模式当中,进行上下文切换,切换完成后,其将会回到用户模式,运行被恢复的进程。在发生调度之后,hello进程的一个时间片就结束了,被恢复进程的时间片就开始了。
除此之外,hello进程中调用了sleep函数,这个函数也会导致进程显示地被挂起,此时内核也会陷入内核模式,主动地进行上下文切换,将控制权转移给其他进程,而当sleep计时结束之后,计时器将会发送一个中断信号给内核,内核将会中断当前的进程,再次到内核模式中切换上下文,恢复hello进程的使用,hello获得了控制权,又是一个新的时间片。其上下文切换的过程大致如下所示:
图6.3 进程上下文切换
6.6 hello的异常与信号处理
Hello进程中可能出现的异常和信号以及处理如下表所示:
操作 | 异常 | 信号 | 处理方法 |
乱打字符并按回车 | 无 | 无 | 如图6.4所示,如果只乱输入字符串的话,输入将会被缓存到stdin,hello在运行结束后将会读取一个以’\n’结尾的字符串,其余的带回车结尾的字符串将会被当做shell的输入命令行处理 |
Ctrl-Z Fg | 中断 | SIGSTP SIGCONT | Hello进程收到一个SIGTSTP信号,内核将强制把该程序挂起,但其并没有被回收,只是在后台并且停止运行,可以调用ps看到该进程,再输入jobs,显示其已停止,如图6.5所示,其作业号为1。再使用fg 1,进程收到SIGCONT信号又被恢复到前台运行。 |
Ctrl-c | 中断 | SIGINT | Hello进程将会收到一个SIGINT信号,进程将彻底终止并且被父进程回收,我们在按下ctrl-c之后,按下ps和jobs都可以发现进程已经被回收,如图6.6所示。 |
Kill -9 | 中断 | SIGKILL | 在使用ctrl-z停止程序后,用ps得到其进程号,再使用kill -9 pid可以将该进程杀死,如图6.7所示。 |
图6.4 随意输入字符串并回车
图6.5 按下ctrl-z并使用有关命令
图6.6 按下ctrl-c
图6.7 用kill发送SIGKILL信号
Pstree命令用进程树的形式将各个进程连接起来:
图6.8 pstree
6.7本章小结
本章介绍了进程的概念和作用,介绍了shell的作用,以及其如何解析命令行,以运行hello为例,阐述了shell生成子进程并运行新程序,以及调度的过程。最后描述了进程如何接收信号,以及如何处理这些信号的过程。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址[4]:逻辑地址是指程序运行时由cpu产生的与段相关的偏移地址部分。
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,以hello为例,当可执行目标文件hello被加载到内存当中之后,其在内存中的真实地址就是物理地址,cpu能够直接通过物理地址找到所需的数据。
线性地址&虚拟地址:是经过段机制转化之后,将逻辑地址转化成的一个虚拟空间中的地址,其能够和物理地址形成抽象映射。以hello为例,虚拟地址的作用就是为其产生了一个独立的虚拟内存空间,并且与真实的物理地址能够一一对应,hello在运行时,cpu能够通过虚拟地址来访问物理地址的内容
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel将逻辑地址分成段选择符,段判别符和地址偏移量的形式。在生成线性地址时,先判断段判别符,确定其是一个局部的段描述符还是一个全局的段描述符,为内核态和用户态的分离作保障。通过组合段描述符和地址偏移量的形式,就可以生成一个线性地址。
图7.1 段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(即虚拟地址)到物理地址之间的转换通过分页的机制完成,即将虚拟内存分割为称为虚拟页的大小固定的块。类似地,物理内存也会被分割为同样大小的物理页。Linux组织的虚拟内存结构如下所示:
图7.2 linux下虚拟内存的组织
在linux下,一个页的大小为4kb。Cpu中有一个MMU(内存管理单元),能够将线性地址翻译为虚拟地址,MMU使用存放在物理内存中被称为页表的数据结构,形成虚拟页到物理页的一个映射。
在不考虑TLB与多级页表的情况下,一个n位虚拟地址被分割为p位的虚拟页面偏移VPO和n-p位的虚拟页号VPN。MMU利用VPN来选择适当的PTE。一个PTE(页表条目)包含了有效位、权限信息、物理页号PPN等信息,如果有效位是0,则在内存中没有该页的信息,如果有效位1,则该内存已经被缓存在物理内存之中,我们可以将PPN和VPO组合起来买得到物理地址PA。
图7.3 使用页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
在core i7的地址翻译过程中,其实现了支持48位虚拟地址空间和52位物理地址空间的寻址。Linux下页表的大小为4kb, 48位虚拟地址空间中,低12位被当做VPO,高36位被当做VPN,且由于有四级页表支持,每级页表使用9位二进制索引。由于一共有16组TLB,故36位VPN中,低四位被作为TLBI,高32位被作为TLBT。
如图7.4所示,每次由CPU产生一个虚拟地址VA, MMU生成一个TLBI,在对应的组中查找是否有与TLBT相匹配的条目,如果找到了匹配的条目,直接取出其中存储的PPN,与VPO组合成物理地址,访问内存;如果TLB发生了一个不命中,MMU将向页表查询物理地址。VPN1, VPN2, VPN3, VPN4,分别确定一二三四级页表中对应的偏移量,找到下一级页表的起始地址,并在第四集页表中找到PPN,并与VPO合并行政物理地址PA。如果任何一次查询发现其不在PTE中,就会引发一次缺页不命中,Cpu将从磁盘中取出对应的页放入内存当中。
图7.4 TLB与四级页表支持下的地址变换
7.5 三级Cache支持下的物理内存访问
由于L1, L2, L3的寻址原理相同,故在此只讨论L1 cache的寻址细节。
在得到了物理地址PA之后之后,由于L1 cache有64组,故CI需要六位,PA的高40位作为CT,根据物理地址在L1 cache中对应的组中寻找是否有CT是否匹配成功。匹配成功则根据偏移量CO取出对应的数据,如果匹配不成功则发生一个不命中,再次向下一级缓存中查询是否有该段数据,如果查询到了数据,则将会根据LRU策略将其放置到更高级的cache当中。
图7.5 Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作室,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
Execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello有效替代了当前的shell子进程。加载并运行hello需要以下几个与内存映射有关的步骤:
- 删除已经存在的用户区域:删除当前进程虚拟地址的用户部分中已经存在的区域结构。
- 映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data去。Bss区域是请求二进制0的,映射到匿名文件,其大小也会包括在hello当中,栈和堆区域也是请求二进制0的,初始长度为0。图7.6概括了私有区域的不同映射。
- 映射共享区域:hello程序与共享对象链接,这些共享对象都将动态地被链接到该程序当中,在映射到用户虚拟地址空间中的共享区域之内。
- 设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
图7.6 加载器是如何映射用户地址空间的区域的
7.8 缺页故障与缺页中断处理
页面命中完全是由硬件来处理的,与之不同的是,处理缺页要求硬件和操作系统内核写作完成,如图7.7所示。如果在匹配PTE时,发现PTE中的有效位是0,MMU将会触发一次异常,产生一个中断,传递CPU中的控制到操作系统内核中的缺页异常处理程序。缺页处理程序将会确定出物理内存中的牺牲页,如果这个页面已经被修改过了,那么将其换出到磁盘当中。之后缺页处理程序调入新的需要的页面,并更新内存中的PTE。之后,缺页处理程序结束中断,返回到原来的进程当中,重新执行导致缺页的指令,CPU将引起缺页的虚拟地址重新发送给MMU,因为虚拟页面现在缓存在物理内存当中,所以就会命中。而在MMU执行了图9-13b中的步骤之后,主存就会将所请求的字返回给处理器。
图7.7 缺页处理
7.9动态存储分配管理
动态内存分配器维护者一个进程的虚拟内存区域,称为堆,如图7.8所示。堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向更高的地址生长,对于每个进程,内核都维护着一个变量brk,其指向了堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用,空闲块可用来分配,其一直保持空闲,直到它显式地被应用分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本分风格,其不同之处在于由哪个实体来负责释放已经分配的块。
- 显式分配器:要求应用显式地释放任何已经分配的块。
- 隐式分配器:其要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器。而自动释放未使用的已分配的块的过程就叫做垃圾收集。
图7.8 堆
分配器需要一些数据结构来区别块的边界,以及已分配块和空闲块。大多数分配器将这些信息嵌入块本身。一个简单的方法如图7.9所示。其允许在常数时间内进行对前面块和后面块的合并。其用头部、有效载荷、填充和脚部组成一个块,其中脚部就是头部的一个副本。头部和脚部包含了块大小的信息,以及块是否被分配的信息。分配器可以通过检查脚部和头部来判断前面和后面的块的起始位置和状态,这种方法称为隐式空闲链表。
图7.9 使用边界标记的堆块形式
一种更好的方法时将空闲块组织为某种形式的显式数据结构,根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这个空闲块的主体里面。例如组织成一个双向空闲链表,如图7.10所示:
图7.10使用双向空闲链表的堆块的格式
使用双向空闲链表使得首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。一种维护链表的方法是后进先出,将新释放的块放置在链表的开始处,使用后进先出的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。而另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章主要介绍了hello的存储其地址空间,段式管理和页式管理的主要原理。以inter core i7为例介绍了VA到PA时如何变换,并利用TLB和页表,cache加速物理内存的访问。同时也介绍了hello进程fork和execve时的内存映射。最后解释了缺页故障与缺页中的所需要做的处理,由于hello的printf使用了malloc函数,也介绍了动态存储分配的管理,一些基本的组织块的数据结构与合并块、分配块
、释放块的方法。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:一个linux文件就是一个m个字节的序列。所有的I/O设备(如网络、磁盘和终端)都被模型化为文件,而所有的输入输出都被当做对应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许linux内核引出一个简单、低级的应用借口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix I/O接口统一操作:
1) 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
2) Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
3) 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
4)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,将会触发EOF,应用程序能够检测到这个条件,但是在文件的结尾处没有明确的”EOF”符号。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5)关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O函数:
- int open(char* filename,int flags,mode_t mode) ,进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
- int close(fd),fd是需要关闭的文件的描述符,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的实现分析
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
Printf函数的函数体如上所示[5],printf的参数个数是不确定的。通过arg, 其被赋值为(char*)(&fmt) + 4),故arg指向的是除了fmt外参数表中的第一个参数。
再看其中vsprintf的内容:
图8.1 vsprintf函数的一个简化实现
Printf要做的是接受一个格式化的命令,并且把指定的匹配的参数进行格式化的输出,其将buf,fmt和arg传递给了vsprintf,而vsprintf返回了一个长度,实际上就是需要打印出来的字符串的长度,vsprintf所做的事情就是接受确定输出格式的格式字符串fmt,用格式字符串根据个数变化的参数进行格式化,并且产生格式化输出,将其写入buf当中,最后返回应当打印的buf的长度。
在调用完vsprintf之后,printf函数调用了write系统函数,在write函数中,其将栈中的参数放入寄存器,如下所示:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
其中ecx存放的参数是字符个数,%ebx存放了需要打印的第一个字符的地址。
随后其产生了一个系统调用syscall。
再看sys_call的实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
该调用将buf中存储的数据”hello 1170300206 zhongweihong”从寄存器中通过总线复制到显卡的显存当中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到对应的点阵信息并存储vram中(存储每一个点的RGB颜色信息)。随后显示芯片按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是我们的打字符串”hello 1170300206 zhongweihong”就显示在了屏幕上。
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,并且产生一个中断请求,键盘将中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区当中。
Getchar在底层调用read系统函数,通过系统调用读取存储在键盘缓冲区当中的按键ascii码,直到接受到回车键才返回整个字符串,由getchar进行封装,但是getchar仅返回字符串的第一个字符。
8.5本章小结
在Linux下,所有的I/O设备都被模型化为文件,而所有的输入和输出都被当做相应文件的读和写来执行。Unix I/O使得所有的输入和输出都能够以一种统一且一致的方式来执行。本章介绍了Linux的I/O设备管理方法,Unix的IO接口以及有关的函数,分析了printf函数以及getchar函数的底层实现。
(第8章1分)
结论
Hello程序完成了从0到0的一生,它的一生所经历的过程如下:
- 编程:由程序员编写C程序代码,并保存到hello.c当中,hello.c完整保存了程序员的目的和要求。
- 预处理:预处理器cpp将hello.c中以#号开头的命令展开,修改原始的C程序,展开宏定义,插入包括的文件中的函数,生成hello.i文件,它的体积变得很大。
- 编译:编译器cc1将hello.i文件编译称为汇编文件hello.s,编译器通过一定的规则,将hello.i文件中的代码转换成汇编代码。并且插入了一些有关文件大小,有关符号名的初始化信息。
- 汇编:汇编器as将hello.s翻译成机器语言指令,并将结果保存在hello.o文件当中,其又被称为可重定位目标程序文件。其中包含了很多节,包括hello运行所需要的各种符号名的信息,各种数据,为了生成位置无关代码,hello.o同时还包含了一些重定位条目,链接器将对其做进一步操作。
- 链接:链接器ld将hello.o和多个共享库连接在一起,其根据重定位条目修改了对应位置的内容,至此hello.o真正变成了一个可执行目标程序hello。
- 运行:我们在shell中输入./hello 1170300206 zhongweihong,shell读取命令行并分析,发现其不是内置命令,则调用fork函数生成一个拥有独立PID的子进程,在子进程的上下文中通过调用execve运行hello程序,1170300206 zhongweihong也被继承作为hello的参数。Execve调用启动加载器,为其构建有关的栈、堆和各种数据结构,将PC调整至程序入口,开始执行hello。
- 访存:hello在cpu为其构建的虚拟内存空间中运行,cpu为hello分配时间片,在hello的时间片中,其独享cpu的资源,拥有控制权,顺序执行自己的逻辑控制流,在执行过程中,hello接收各处发送的信号,并坚决执行默认的信号处理程序,被挂起,被停止...在需要访问内存时,MMU在TLB和页表的通力合作下将虚拟地址VA转换为PA,在cache中寻找需要的内容,亦或将磁盘中的内容调入cache当中。Hello中的printf将会调用malloc,向动态内存分配器申请堆当中的内存。
- 显示:hello调用了printf函数,将有关的参数传递给它,printf通过底层的调用将字符串传递给显存,最终在屏幕上显示出需要打印的字符串。
- 终止,回收:hello的运行完毕,shell父进程收到了hello子进程终止的信号,将子进程回收,内核删除了为子进程创建的所有内存空间和各种数据结构。
计算机系统课是一门重要而深入的课程,计算机系统的设计与实现有无穷的奥妙,hello带我们走过了第一段路,接下来的路,还要一步一步去走,计算机的奥秘还需要一点点挖掘。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
中间文件名 | 作用 |
Hello.i | 预处理器产生的文件,根据其中#开头的命令修改原来的程序 |
Hello.s | 编译器产生的文件,包含一个汇编语言程序 |
Hello.o | 汇编器产生的一个二进制文件,包含了一些重定位条目和机器语言指令以及数据段,符号表等 |
Hello | 由链接器产生的可执行目标文件,链接器修改了重定位条目,将多个.o文件链接在一起生成了一个可以被加载到内存并执行的文件 |
Hello_elf | Hello的elf格式文件 |
Hello_o_elf | Hello.o的elf格式文件 |
Hello_objdump | Hello的反汇编文件 |
Hello_o_objdump | Hello.o的反汇编文件,带有重定位条目标注信息 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
- GCC编译器原理 https://www.cnblogs.com/kele-dad/p/9490640.html
- Preprocessor Output https://gcc.gnu.org/onlinedocs/cpp/Preprocessor-Output.html
- 什么是shell https://www.cnblogs.com/hihtml5/p/9272751.html
- 虚拟地址、逻辑地址、线性地址、物理地址:https://blog.csdn.net/rabbit_in_android/article/details/49976101
- Printf函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html
(参考文献0分,缺失 -1分)