计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 1190202107
班 级 1936602
学 生 姚舜宇
指 导 教 师 刘宏伟
计算机科学与技术学院
2021年6月
摘 要
本文利用计算机系统这门课所学的知识,以hello.c程序为例,描述了它的从编写完成到终止的历程。包括预处理、编译、汇编、链接、进程管理、存储管理、IO管理的知识,加强了我们对于计算机系统和程序执行流程的理解。
关键词:计算机系统;hello
目 录
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 5 -
2.1 预处理的概念与作用 - 5 -
2.2在Ubuntu下预处理的命令 - 5 -
2.3 Hello的预处理结果解析 - 5 -
2.4 本章小结 - 5 -
第3章 编译 - 6 -
3.1 编译的概念与作用 - 6 -
3.2 在Ubuntu下编译的命令 - 6 -
3.3 Hello的编译结果解析 - 6 -
3.4 本章小结 - 6 -
第4章 汇编 - 7 -
4.1 汇编的概念与作用 - 7 -
4.2 在Ubuntu下汇编的命令 - 7 -
4.3 可重定位目标elf格式 - 7 -
4.4 Hello.o的结果解析 - 7 -
4.5 本章小结 - 7 -
第5章 链接 - 8 -
5.1 链接的概念与作用 - 8 -
5.2 在Ubuntu下链接的命令 - 8 -
5.3 可执行目标文件hello的格式 - 8 -
5.4 hello的虚拟地址空间 - 8 -
5.5 链接的重定位过程分析 - 8 -
5.6 hello的执行流程 - 8 -
5.7 Hello的动态链接分析 - 8 -
5.8 本章小结 - 9 -
第6章 hello进程管理 - 10 -
6.1 进程的概念与作用 - 10 -
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
6.3 Hello的fork进程创建过程 - 10 -
6.4 Hello的execve过程 - 10 -
6.5 Hello的进程执行 - 10 -
6.6 hello的异常与信号处理 - 10 -
6.7本章小结 - 10 -
第7章 hello的存储管理 - 11 -
7.1 hello的存储器地址空间 - 11 -
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 -
7.9动态存储分配管理 - 11 -
7.10本章小结 - 12 -
第8章 hello的IO管理 - 13 -
8.1 Linux的IO设备管理方法 - 13 -
8.2 简述Unix IO接口及其函数 - 13 -
8.3 printf的实现分析 - 13 -
8.4 getchar的实现分析 - 13 -
8.5本章小结 - 13 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -
第1章 概述
1.1 Hello简介
P2P:
Program:用户在编辑器或IDE中键入代码得到程序。
Process:在Linux中,hello.c经过C预处理其cpp的预处理成为文本文件hello.i, C编译器ccl的编译成为汇编文件hello.s,汇编其as的汇编成为二进制可重定 位目标文件hello.o,最终经过 ld的链接,成为可执行程序 hello。
020:
OS进程管理通过fork和execve产生子进程和加载并运行程序,进行虚拟内存的映射。CPU通过取指,译码,执行,访存,写回,更新PC的操作,一条一条执行指令。涉及到输入输出时,IO管理对函数进行处理。运行结束后,shell回收hello进程,并消除相关的数据等痕迹。
1.2 环境与工具
硬件工具:X64 Intel Core i5-8300H CPU,2.30GHz,8G RAM,512GHD DISK
软件工具:Windows10 64位,VMware Workstation 16 Pro,Ubuntu 20.04.2.0
开发与调试工具:Codeblocks,gcc gdb,vim,readelf,objdump等
1.3 中间结果
hello.i 对hello.c进行预处理得到的文件
hello.s 对hello.i进行编译得到的文件
hello.o 对hello.s进行汇编得到的文件
hello 对hello.o进行链接得到的可执行文件
hello.elf hello.o的ELF格式
hello-run.elf hello的ELF格式
hello_objdump.txt 对hello.o进行反汇编得到的文件
hello-d-r.txt 对hello进行反汇编得到的文件
1.4 本章小结
本章对hello进行了一个简介,并对其运行流程进行了一个总体的概括。然后介绍了所有操作的环境与工具,以及描述了中间产生的文件信息。
第2章 预处理
2.1 预处理的概念与作用
预处理是在编译之前对源文件提前进行的处理,可以算是一种展开。
预处理通过扫描源代码,对其进行初步转换,产生新的源代码提供给编译器。预处理过程读入源代码,检测包含预处理指令的语句和宏定义,并进行相应的转换,以及删除注释和多余的空白符。
预处理的主要作用如下:
1.将源文件中以”include”格式包含的文件复制到编译的原文件中;
2.用实际值替换以”#define”定义的字符串;
3.根据”#if”后面的条件决定需要编译的代码,即条件编译;
4.删除文件中的注释和多余的空白符。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
可以看到,原本很短的hello.c经过预处理之后生成的hello.i有数千行,这是预处理过程完成了对头文件的展开、宏定义的替换、去除注释、条件编译等内容。
例如,头文件<stdio.h>在hello.i文件中的展开情况是从第13行到728行,里面包含了各种typedef和extern函数等。
在hello.i文件的末尾处,是源程序的主体部分。
2.4 本章小结
本节主要介绍了预处理的概念和功能,预处理是一个编写完的程序要进行的第一个步骤。预处理主要由预处理器完成,内容主要是展开头文件,宏定义替换,条件编译,去除无关注释。
第3章 编译
3.1 编译的概念与作用
编译是把通常为高级语言的源代码(这里指经过预处理而生成的hello.i)到能直接被计算机或虚拟机执行的目标代码(这里指汇编文件hello.s)的翻译过程。
编译的主要作用如下:
1.词法分析,词法分析器读入组成源程序的字符流并将其组成有意义的词素的序列,即将字符序列转换为单词序列的过程。
2.语法分析,语法分析器使用词法分析器生成的各词法单元的第一个分类来创建树形的中间表示,在词法分析的基础上将单词序列组合成各类语法短语。该中间表示给出了词法分析产生的词法单元的语法结构,常用的表示方法为语法树。
3.语义分析,语义分析器使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致,它同时收集类型信息,并存放在语法树或符号表中,为代码生成阶段做准备。
4.代码生成和优化,在源程序的语法分析和语义分析完成后,会生成一个明确的低级的或类及其语言的中间表示。代码优化试图改进中间代码,生成执行所需要时间和空间更少。最后代码生成以中间表示形式为输入,并把它映射为目标语言。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1 文件内容简介
.file 文件命名
.text 代码段
.global 全局变量
.data 数据段
.align 对齐方式
.type 类型
.size 大小
.long long类型
.string string类型
.section .rodata 只读数据段
3.3.2 数据
hello.s中使用到的数据包括:sleepsecs,argc,argv,i,字符串,立即数。
1.sleepsecs:这是定义时被初始化的全局变量。.data节保存已初始化的全局和静态C变量,所以编译器首先将sleepsecs在.text节中声明为全局变量,然后将其保存在.data节中,并设置对齐方式为4字节,类型为object,大小为4字节。在后面,sleepsecs又被标记为long类型,值为2,保存在只读代码段中。
2.argc:这是传给main函数的第一个参数,表示命令行参数的个数。根据hello.c和hello.s文件推断,argc保存在地址为-20(%rbp)的栈中。
3.argv:它的完整声明为char *argv[],是一个数组,main函数的第二个参数。它的首地址保存在地址为-32(%rbp)的栈中。
后续调用时,通过对地址进行加法运算,调用数组内部的元素。下图中先让寄存器%rax指向数组首地址,然后加偏移量16,再解引用。再次让寄存器%rax指向数组首地址,然后加偏移量8,再解引用,这样就调用了数组的第一个和第二个元素。
4.i:这是程序运行中定义的局部变量。根据hello.c文件中的循环可以推断,i保存在地址为-4(%rbp)的栈中。
5.字符串:在hello.c中,可以看到两个字符串。
在hello.s中,可以看到字符串
调用字符串时,使用语句
将这两个字符串作为printf函数的参数。
6.立即数:立即数不需要寄存器或者栈的空间,直接表示在汇编代码中。
3.3.2 控制与操作
1.赋值操作
赋值操作在hello.c中有对全局变量sleepsecs的初始化,对局部变量i的初始化以及自增运算i++。
1)int sleepsecs=2.5
sleepsecs是已经初始化的全局变量,在.data节中,值为2,数据类型为long。
2)int i=0
这是在循环入口处进行的初始化。
在hello.s文件的.L2处,可以看到局部变量i存储在-4(%rbp)处,并且用movl指令将其赋值为0。
3)i++
这是在每次循环结束时对i进行自增操作。
在hello.s文件的.L4末尾,用addl指令将存储在-4(%rbp)处的i加1。
2.类型转换
出现类型转换的位置是全局变量sleepsecs。
在主函数main之外,使用int sleepsecs=2.5进行初始化。但后来被转换为值为2的long型变量。因为在初始化的时候,赋的数据是2.5,是浮点类型。所以进行向0舍入到2。而默认的浮点类型为8字节的double,所以后来强制转换为整型时转换为8字节的long型。
3.算术操作
一般而言,算数操作包括加、减、乘、除、模等,但因为hello.c中出现的算术操作较少,所以这里只讨论加减运算和hello.s中涉及加减的汇编指令。
1)i++
在hello.s文件中,i++对应的汇编指令为addl $1, -4(%rbp)。即将地址为-4(%rbp)的数据进行加1。
2)汇编指令中的算术操作
subq $32, %rsp 这里是将%rsp的值减32。因为%rsp是栈顶指针,所以这条指令的意义是在栈顶处开辟4字节的空间。
leaq .LC1(%rip), %rdi 这是加载有效地址的语句,也是算数操作,因为它涉及到计算有效地址。这句指令的意义是计算地址.LC1+%rip,并且传递给%rdi。
addl $1, -4(%rbp) 这里是将i的值加1。前面已经讨论过,i就局部变量,地址是-4(%rbp)。
4.逻辑/位操作
一般而言,逻辑/位操作包括与、或、非、异或、同或、移位等。在hello.c和hello.s中,并未出现逻辑/位操作,所以这里不予讨论。
5.关系操作
常见的涉及到关系操作的汇编指令包括cmp、test、jmp以及条件跳转等。下面就文件中出现的关系操作进行讨论。
1)
这条指令是将地址为-20(%rbp)的值与立即数3进行比较,并设置条件码。根据条件码的值,进行下一步的执行。下一条指令为je .L2,表示如果地址为-20(%rbp)的值等于3,则跳转到.L2的首地址开始执行。对应的C语言的语句为
但这里是如果参数不等于3则执行括号里的内容。对于汇编代码,对其进行了一定的优化,如果参数等于3,则执行相关内容。
2)
这一条指令同上一条类似,将地址为-4(%rbp)的值即局部变量i与立即数9进行比较,如果i小于或等于9,则跳转到.L4的首地址开始执行。在hello.c中,循环的判断条件是i<10,编译过程中将其优化为i<=9。
6.数组/指针/结构操作
1)数组与指针操作
hello.c中涉及到的数组操作只有char *argv[]。这是一个数组指针。先查看汇编代码。
首先在这里,是将保存在寄存器里的参数放入内存中,将%edi的内容赋给-20(%rbp)地址的内容,将%rsi的内容赋给-32(%rbp)地址的内容,现在-32(%rbp)就是指向数组首地址的指针。
这里是引用数组下标为1和2的元素,即argv[1]和argv[2]。首先将数组首地址赋给%rax,然后%rax+16即第三个元素的首地址,然后将该地址的内容取出,赋给%rdx。下面再将数组首地址赋给%rax,然后%rax+8即第二个元素的首地址,然后将该地址的内容取出,赋给%rax。
2)结构操作
在hello.c中,没有定义结构,所以这里不予讨论。
7.控制转移
控制转移在上文的关系操作中有了一定的解释,一般的控制转移指令有jmp、je、jle、ja等,针对不同的条件码进行转移。
1)
控制转移指令为je .L2,表示如果地址为-20(%rbp)的值等于3,则跳转到.L2的首地址开始执行。对应的C语言的语句为
控制转移指令为jle .L4,如果i小于或等于9,则跳转到.L4的首地址开始执行。
3)在.L2中,有一条无条件跳转指令jmp。
这条指令将会让程序无条件跳转到.L3首地址的位置开始执行。
8.函数操作
1)参数传递
main函数:函数有两个参数,int argc,char *argv[],第一个参数是一个int型整数,第二个参数是一个指针,即值是一个地址,它们分别存储在寄存器%edi和%rsi中,在函数中,将参数放入栈中保存。
printf函数:在编译过程中,有一处printf函数被优化为puts函数。在整个程序中,调用了两次输出的函数。
第一处调用在判断体中。
在调用函数之前,将.LC0(%rip)的值即字符串"Usage: Hello 1190202107 姚舜宇!\n"的首地址传递给%rdi,所以%rdi为传入给函数puts的参数。
第二处调用在循环体中。
在调用函数之前,将.LC1(%rip)的值即字符串"Hello %s %s\n"的首地址传递给%rdi,所以%rdi为传入给函数printf的参数。
sleep函数:查看hello.c文件的内容,知道函数sleep的参数有一个,sleepsecs。查看hello.s文件的内容,在调用函数之前,执行movl sleepsecs(%rip), %eax、movl %eax, %edi将参数sleepsecs传入函数sleep。
exit函数:对于exit函数,传参较为简单。将寄存器%edi的值赋为1,然后调用函数。
getchar函数:并没有设置参数,进行条件判断之后直接调用函数。
2)函数调用
main函数:由系统调用,首先在运行时通过动态链接,调用libc库里的函数__libc_start_main,然后这个函数会初始化程序,执行__init,注册退出处理程序,再调用main函数。
printf函数:由指令call printf@PLT调用。先将该指令的下一条指令地址压入栈中,然后进入该函数,函数执行结束后,执行调用printf函数指令下一条指令的地址。
sleep函数:由指令call sleep@PLT调用,过程同上。
exit函数:由指令call exit@PLT调用,过程同上。
getchar函数:由指令call getchar@PLT调用,过程同上。
3)函数返回
main函数:程序结束时,将%eax设置为0,然后调用leave。leave相当于调用mov %rbp,%rsp和pop %rbp,将栈恢复为最初的状态。然后调用ret返回。
其他函数返回时将栈恢复为调用该函数之前的状态,此时栈顶的元素就是调用该函数的指令的下一条指令的地址。然后执行该下一条指令即可。
3.4 本章小结
本章着重介绍了编译的概念和作用,并且以hello.i到hello.s为例,分析了编译器是如何处理C语言的各个数据类型以及各类操作,包括字符串等各类数据,赋值操作,类型转换,算术操作,逻辑/位操作,关系操作,数组/指针/结构操作,控制转移,函数操作的内容。经过这部分的讨论与分析,对编译更加了解了。
第4章 汇编
4.1 汇编的概念与作用
编译完成生成hello.s文件后,驱动程序运行汇编器as,将hello.s翻译成一个可重定位目标文件hello.o,这个过程就是汇编。
汇编的作用主要就是将编译的结果hello.s转化为机器可识别并执行二进制文件。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
1)可重定位目标文件的ELF格式简介
ELF头 包括16字节标识信息、文件类型、机器类型、节头表的偏移、表项大小及个数
.text节 编译后的代码部分
.rodata节 只读数据
.data节 已初始化的全局和静态C变量
.bss节 未初始化的全局和静态C变量
.symtab节 符号表,存放在程序中定义和引用的函数和全局变量的信息
.rel.txt节 一个.text节中位置的列表
.rel.data节 被模块引用或定义的所有全局变量的重定位信息
.debug节 一个调试符号表,条目是程序中定义的局部变量和类型定义
.strtab节 一个字符串表,内容包括.symtab和.debug节中的符号表,以及节头部中的节名字
.line节 原始C源程序中的行号和.text节中机器指令之间的映射
Section header table(节头部表) 每个节的节名、偏移和大小
2)读取可重定位目标文件的ELF格式
命令:readelf -a hello.o > hello.elf
3)可重定位目标文件ELF格式的分析
ELF头:以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如X86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。
hello.o的ELF格式的ELF头如下图:
节头部表:详细标识了每个节的名称、类型、地址、偏移量、大小、读写权限、对齐方式等。如.text节,类型为PROGBITS,起始地址为0,偏移量为40,大小为85,属性为AX,即可装入可执行,对齐方式为1字节。
完整的节头部表如下图:
.rel重定位节:在hello.elf里出现了重定位节.rela.text和重定位节.rela.eh_frame。它的内容有偏移量、信息、类型、符号值、符号名称、加数。在重定位节.rela.text中,可以看到符号名称有:.rodata,puts,exit,.rodata,printf,sleepsecs,sleep,getchar。具体数据如下图:
.symtab节:存放在程序中定义和引用的函数和全局变量的信息,具体数据如下图:
这里存放了17个条目,可以看到序号为10到17的条目存放了全局变量sleepsecs,_GLOBAL_OFFSET_TABLE,以及各种函数:main,puts,exit,printf,sleep,getchar。
4.4 Hello.o的结果解析
objdump -d -r hello.o > hello_objdump.txt
打开txt文件,发现它是根据.text节进行的反汇编。
接下来分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。通过比较可以发现,在以下方面存在区别:控制转移的结构、全局变量的引用方式、函数调用的方法。
1)控制转移的结构
首先观察hello.s中的控制转移结构:
可以看到编译过程以.L1、.L2、.L3等名称来标记各个段,跳转指令直接描述需要跳转到的段的名称。
再看反汇编得到的汇编代码:
可以看出是根据指令的位置相对于main函数地址的偏移量进行定位并且跳转的。
2)全局变量的引用方式
对全局变量sleepsecs的引用,两者也存在差别。
在hello.s中,如下图,是根据段的首地址加%rip得到保存在%eax中,然后转递给%edi。
在hello.o反汇编得到的汇编代码中,如下图,看上去也是通过%rip加一个偏移量得到保存在%eax中,然后转递给%edi。但不同的是,这个偏移量是0。这是因为全局变量的地址在运行时通过重定位确定,在当前情况下通过0来占位。
3)函数调用的方法
以sleep函数为例,在hello.s中,指令call后直接加函数名称。
在反汇编代码中,call后的地址就是该条指令下一条指令的地址,并没有函数的首地址。这是因为这些函数需要通过动态链接确定地址,所以当前只是在.rela.text重定位节中保留了函数的信息,等待动态链接进行调用。
最后来说明机器语言的构成,与汇编语言的映射关系。机器语言是一种二进制语言,每一条指令、数据都由二进制来表示。汇编语言用了助记符,对于很多指令的二进制编码,用一个字符串来表示,让程序员更容易读懂。另外反汇编代码不仅显示了汇编代码,还显示了二进制代码。综上可以认为机器语言和汇编语言的映射是一种双射。
4.5 本章小结
本章着重介绍了汇编的概念和作用,并且以hello.s到hello.o为例,介绍并分析了可重定位目标文件的ELF格式,以及对hello.o的结果进行了解析,将编译的结果hello.s与对hello.o的反汇编代码hello_objdump.txt进行比较,了解了汇编代码和反汇编代码的一些结构和内容上的区别。通过这些讨论,增强了对汇编过程的理解。
第5章 链接
5.1 链接的概念与作用
汇编过程结束生成hello.o文件后,驱动程序运行链接器程序ld,将hello.o和其他一些必要的系统目标文件组合起来,创建一个可执行目标文件。这个过程就是链接。
作用:链接可以将各种代码和数据片段收集并组合策划归纳为一个可以加载到内存并执行的单一文件。它使得分离编译成为可能,可以将一个大型的应用程序分解为更小,更好管理的模块,便于独立修改和编译。链接让程序员能够利用共享库,通过动态链接为程序提供动态内容。
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.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
1)可执行目标文件的ELF格式简介
可执行目标文件的ELF格式与可重定位目标文件的ELF格式较为相似,稍有不同。
ELF头 字段e_entry给出执行程序时第一条指令的地址 只读代码段
程序头表 结构数组
.init节 用于定义_init函数,该函数用来进行可执行目标文件开始执行的初始化工作
.text节 编译后的代码部分
.rodata节 只读数据
.data节 已初始化的全局和静态C变量 读写数据段
.bss节 未初始化的全局和静态C变量
.symtab节 符号表,存放在程序中定义和引用的函数和全局变量的信息 无需装入到存储空间的信息
.debug节 一个调试符号表,条目是程序中定义的局部变量和类型定义
.strtab节 一个字符串表,内容包括.symtab和.debug节中的符号表,以及节头部中的节名字
.line节 原始C源程序中的行号和.text节中机器指令之间的映射
节头表 每个节的节名、偏移和大小
2)读取可执行目标文件的ELF格式
命令:readelf -a hello > hello-run.elf
3)可执行目标文件ELF格式的查看
ELF头标记了这是一个可执行文件,并且给定了入口点地址。
节头给定了各个部分的具体信息,具体地址。(由于节头过长不宜截图,所以这里仅为节头的一部分)
程序头表,给定了各个部分的具体信息,包括虚拟地址,物理地址。
5.4 hello的虚拟地址空间
使用edb加载hello,如下图:
根据下图,可以看出hello的虚拟地址从0x401000开始,到0x402000结束。
接下来将此与5.3中的节头进行对比。
1).init节,起始地址0x401000,大小0x1b
2).plt节,起始地址0x401020,大小0x60
3).plt.sec节,起始地址0x401080,大小0x50
4).text节,起始地址0x4010d0,大小0x135
5).fini节,起始地址0x401208,大小0xd
5.5 链接的重定位过程分析
(以下格式自行编排,编辑时删除)
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
利用命令objdump -d -r hello hello-d-r.txt,将反汇编文件输出到txt文本中。
1)hello与hello.o的不同以及链接的过程
首先看到使用hello反汇编得到的文件中,对于每一条指令、节、函数,都有了一个以40开头的虚拟地址,和上一节在edb中看到的地址相同。而hello.o反汇编得到的文件中,在相应位置都是由相对偏移量来表示的。
可以观察到hello-d-r.txt比hello_objdump.txt要多出很多内容。包括.init节、.plt节、.plt.sec节、.fini节等。而hello_objdump.txt只有.text节。并且动态链接库里面的函数也已经在.plt.sec节中了,如下图:
链接的过程:1.符号解析。程序中有定义和引用的符号,存放在符号表.symtab节中。这是一个结构数组,存放在程序中定义和引用的函数和全局变量的信息。编译器将符号的引用存放在重定位节.rel.text节以及.rel.data节中,链接器将每一个符号的引用都与一个确定的符号定义建立关联。2.重定位。将多个代码段和数据段分别合并为一个完整的代码段和数据段,计算每一个定义的符号在虚拟地址空间的绝对地址而不是相对偏移量,将可执行文件中的符号引用处修改为重定位后的地址信息。
2)hello中如何重定位
下图是链接器重定位算法的伪代码。假设每个节s是一个字节数组,每个重定位条目r是一个类型为Elf64_Rela的结构,定义如下。另外,假设算法运行时,链接器已经为每个节(用ADDR(s)表示)和每个符号都选择了运行时地址(用ADDR(r.symbol)表示)。算法首先计算需要被重定位的4字节引用的数组s中的地址。如果这个引用是PC相对寻址,则用第一个if结构进行处理。如果该引用使用的是绝对寻址,则通过第二个if结构处理。
接下来以函数sleep进行举例说明。
在hello_objdump.txt中,函数main调用sleep函数。Call指令开始于节偏移0x6a的地方,包括1字节的操作码0xe8,后面跟着的是对目标sleep的PC相对引用的占位符。
在代码的重定位节.rela.text中,可以看到偏移量r.offset=0x6b,用于偏移调整的值r.addend=-4。
在hello的反汇编文件中,查找到sleep的首地址为0x4010c0,即ADDR(r.symbol)=0x4010c0。
最后ADDR(s)=0x401105。
由重定位算法,链接器首先计算出引用的运行时地址。
refaddr=ADDR(S)+r.offset=0x401105+0x0x6b=0x401170。
然后,更新该引用,使得它在运行时指向sleep函数。
*refaddr=(unsigned)(ADDR(r.symbol)+r.addend-refaddr)
=(unsigned)(0x4010c0+(-4)-0x401170)
=(unsigned)(0xffffff4c)
验证,与hello的反汇编结果一致。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
函数名 地址
<do_init> 0x00007f3f02e9edf0
<hello!_start> 0x00000000004010d0
libc-2.31.so!__libc_start_main> 0x00007f3f02cbafc0
< libc-2.31.so!__cxa_atexit> 0x00007f3f02cddf60
< hello!__libc_csu_init> 0x0000000000401190
< libc-2.31.so!_setjmp> 0x00007f3f02cd9e00
< hello!main> 0x0000000000401105
< hello!puts@plt> 0x0000000000401030
< hello!exit@plt> 0x0000000000401060
5.7 Hello的动态链接分析
对于动态共享库中的PIC函数,编译器无法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任何位置,一般是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。编译器使用延迟绑定的技术将过程地址的绑定推迟到第一次调用过程时。延迟绑定通过GOT和过程链接表(PLT)这两个数据结构的交互来实现。GOT是数据段的一部分,PLT是代码段的一部分。GOT和PLT通过协作在运行时解析函数的地址。
GOT和PLT在dl_init被第一次调用时,延迟解析它的运行时地址的步骤:
1.不直接调用dl_init,程序调用进入PLT[2],这是dl_init的PLT条目。
2.第一条PLT指令通过GOT[4]进行间接跳转。因为每个GOT条目初始时都指向它对应的PLT条目的第二条指令,这个间接跳转只是简单地把控制传送回PLT[2]中的下一条指令。
3.在把dl_init的ID压入栈中之后,PLT[2]跳转到PLT[0]。
4.PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]间接跳转进动态链接器中。动态链接器使用两个栈条目来确定dl_init的运行时位置,用这个地址重写GOT[4],再把控制流传递给dl_init。
下图是在命令行中用readelf命令查看.got.plt信息的结果,其首地址为0x404000,大小为0x40字节。
dl_init函数之前:
调用dl_init函数:
dl_init函数之后:
对比这两个表,发现地址0x404008处的内容由00 00 00 00 00 00 00 00变成了90 c1 ec 02 3f 7f 00 00。用小端法表示为00 00 7f 3f 02 ec c1 90。地址0x404010的内容由00 00 00 00 00 00 00 00变成了b0 5b eb 02 3f 7f 00 00。用小端法表示为00 00 7f 3f 02 eb 5b b0。这里的变化是因为在程序调用函数dl_init前,编译器无法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。所以使用延迟绑定技术,将过程地址的绑定推迟到调用这个函数的时刻。在调用这个函数时,.got.plt的某些条目就发生了变化。
5.8 本章小结
本章介绍了链接的概念和作用,以及以hello为例,分析了可执行文件的ELF格式、虚拟地址空间、将hello的反汇编文件和hello.o的反汇编文件进行比较,并举例计算了重定位的过程。经过这一部分的讨论,我对链接的过程更加理解了。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程就是一个执行中程序的实例。是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
作用:在现代系统上运行一个程序时,进程会提供一个假象,好像我们的程序是系统中当前运行的唯一的程序一样。程序好像是独占地适用处理器和内存,处理器就好像是无间断地一条接一条地执行我们程序中的指令,而且程序中的代码和数据好像是系统内存中唯一的对象。进程提供给程序的关键抽象,一是一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地适用处理器。二是一个私有的地址空间,它提供一个假象,好像我们的程序独占地适用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:
shell最重要的功能是命令解释。shell是一个命令解释器。用户提交了一个命令后,shell首先判断它是否为内置命令,如果是就通过shell内部的解释器将其解释为系统功能调用并转交给内核执行;若是外部命令或使用程序就试图在硬盘中查找该命令并将其调入内存,再将其解释为系统功能调用并转交给内核执行。
处理流程:
shell打印一个命令行提示符,等待用户在stdin上输入命令行,然后对这个命令行求值。命令行求值的首要任务是调用parseline函数,这个函数解析了以空格分割的命令行参数,并构造最终会传递给execve的argv向量。第一个参数被假设为要么是一个内置的shell命令名,马上就会解释这个命令,要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。
在解析了命令行之后,eval函数调用builtin_command函数,该函数检查第一个命令行参数是否是一个内置的shell命令。如果是,它就会易理解释这个命令,并返回值1。否则返回0,shell创建一个子进程,并在子进程中执行所请求的程序。如果用户要求在后台印象该程序,那么shell返回到循环的顶部,等待下一个命令行。否则,shell使用waitpid函数等待作业终止。当作业终止时,shell就回收子进程,并开始下一轮迭代。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。fork函数只被调用一次,但会返回两次。一次是在调用进程中,一次是在新创建的子进程中。在父进程中,fork返回子进程的pid,在子进程中,fork返回0。
创建过程:
1.给新进程分配一个标识符。
2.在内核中分配一个PCB(进程管理块),将其挂在PCB表上。
3.复制它的父进程的环境(PCB中大部分的内容)。
4.为其分配资源(程序、数据、栈等)。
5.复制父进程地址空间里的内容(代码共享,数据写时拷贝)。
6.将进程设置成就绪状态,并将其放入就绪队列,等待CPU调度。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。
Int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境遍历列表envp。只有当出现错误时,execve才会返回到调用程序。所以,execve调用一次并从不返回。
在execve加载了filename后,调用启动代码,启动代码设置栈,并将控制转移传递给新程序的主函数。
当main开始执行时,用户栈的组织结构如下。从栈底(高地址)往栈顶(低地址)依次观察。首先是参数和环境字符串。栈往上是以null结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串。全局变量environ指向这些指针中的第一个envp[0]。紧随环境变量数组之后的是以null结尾的argv[]数组,其中每个元素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数libc_start_main的栈帧。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
进程的上下文:上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程时间片:一个进程执行它的控制流的一部分的每一个时间段叫做时间片。
进程的调度:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。当内核选择一个新的进程运行时,就说内核调度了这个进程。当内核调度了一个新的进程运行后,它就抢占当前进程,并通过上下文切换的机制将控制转移到新的进程。上下文切换会保存当前进程的上下文,恢复某个先前被抢占的进程被保存的上下文,将控制传递给这个新恢复的进程。当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个时间发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。中断也可能引发上下文切换。
用户态与内核态的转换:进程为hello程序分配了虚拟地址空间,并将hello的代码节和数据节分配到虚拟地址空间的代码区和数据区。首先hello在用户模式下运行,调用系统函数sleep,显式地请求让调用进程休眠。这时就发生了进程的调度。用户模式和内核模式的转换示意图如下:
6.6 hello的异常与信号处理
hello的异常(中断、陷阱)以及其处理方式。
1.中断:中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。
处理方式:在当前指令完成执行后,处理器注意到中断引脚的电压变高,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,它就将控制返回给下一条指令。结果是程序继续执行,就像没有发生过中断一样。示意图如下:
2.陷阱:陷阱是有意的异常,是执行一条指令的结果。
处理方式:陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。用户程序经常需要像内核请求服务,为了允许这些内核服务的受控的访问,处理器提供了一条特殊的“syscall n”指令每当用户程序想要请求服务n时,可以执行这条命令。执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。示意图如下:
运行时按Ctrl-Z:内核发送一个SIGTSTP信号到前台进程组中的每个进程。默认情况下,结果是停止前台作业。
之后运行ps命令:ps命令用于显示当前进程的状态。
之后运行jobs命令:用于显示任务列表和任务状态。
之后运行pstree命令:查看进程树之间的关系。
之后运行fg命令:将后台作业(在后台运行或在后台挂起)放到前台运行。
之后运行kill -9 %1命令:kill命令用于发送一个信号到一个进程。
重新运行时按Ctrl-C:内核发送一个SIGINT信号到前台进程组中的每个进程。默认情况下结果是终止前台作业。
运行过程中乱按键盘:不会影响程序的运行。
信号的处理:
1.对于Ctrl-Z:内核发送一个SIGTSTP信号到前台进程组中的每个进程。默认情况下,结果是停止前台作业。
2.对于Ctrl-C:内核发送一个SIGINT信号到前台进程组中的每个进程。默认情况下结果是终止前台作业。
3.对于fg信号:将后台作业(在后台运行或在后台挂起)放到前台运行。
4.对于kill命令发送的信号:信号编号是9,即SIGKILL,终止。kill -9 %1是杀死后台hello程序。
6.7本章小结
这一章主要学习了异常控制流,进程,信号的处理。这一章的重要性在于讲述了应用是如何与操作系统交互的。这些交互都是围绕着ECF(异常控制流)的。从异常开始,异常位于硬件和操作系统交界的部分。系统调用是为应用程序提供到操作系统的入口点的异常。还有进程和信号,它们位于应用和操作系统的交界之处。学习这一章对于理解用户程序和系统内核的交互有重要的作用。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:是在有地址变换功能的机器中访内指令给出的地址。也叫相对地址,也就是在机器语言指令中,用来指定一个操作数或是一条指令的地址。要经过寻址方式的计算才能得到内存储器中的实际有效地址。在hello中,生成的hello.o文件中的地址即偏移量,都是逻辑地址。
线性地址:如果地址空间中的整数是连续的,那么就说它是一个线性地址空间。在这里讨论的线性地址就是虚拟地址。
虚拟地址:是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。通常是一个32为无符号整数,可以用来表示4GB的地址。线性地址通常用十六进制数字表示。程序会产生逻辑地址,通过变换就可以生成线性地址,如果有分页机制,则线性地址可以再映射出一个物理地址。在hello中,对hello可执行文件进行反汇编得到的文本文件中的地址都是虚拟地址,在这里也就是线性地址。
物理地址:真实的存储器中的地址,由CPU地址总线传来,硬件电路控制其具体含义。物理地址中很大一部分是留给内存条中的内存的,也常常被映射到其他的存储器上。在没有使用虚拟存储的机器上,虚拟地址被直接送到内存总线上,使用具有相同地址的物理存储器被读写。在使用了虚拟存储的机器上,虚拟地址经过MMU地址翻译后映射成为物理地址,在内存中进行读写。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址向线性地址的转换过程如下图。
逻辑地址包含16位的段选择符和32位的段内偏移量。MMU首先根据段选择符中的TI确定选择全局描述符表GDT还是局部描述符表LDT。确定描述符表后,再通过段选择符内的13位索引值从被选中的描述符表中找到对于的段描述符。因为每个段描述符占8个字节,所以位移量位索引值乘8,加上描述符表首地址,就可以确定选中的段描述符的地址,从中取出32位的基地址,与逻辑地址中32位的段内偏移量相加,就得到了32位线性地址。
通常情况下,MMU不需要到主存中访问GDT或LDT,只要根据段寄存器对于的描述符cache中的基地址、限界和存取权限来进行逻辑地址到线性地址的转换,如下图。
7.3 Hello的线性地址到物理地址的变换-页式管理
如下图。线性地址向物理地址的转换过程如下:首先,根据控制寄存器CR3给出的页目录表首地址找到页目录表,由DIR字段提供的页目录索引找到对应的页目录项;然后根据页目录项中的基地址指出的页表首地址找到对应的页表,再根据线性地址中间的页表索引找到页表中的页表项;最后将页表项中的基地址和线性地址中的12位页内偏移量组合成32位物理地址。
其中页目录项和页表项的格式如下图。
P:P=1表示页表或页在主存中;P=0表示页表或页不在主存中。
R/W:该位为0时表示页表或页只能读不能写;为1时表示可读可写。
U/S:该位为0时表示用户进程不能访问;为1时允许用户进程访问。
PWT:用来控制页表或页对应的cache写策略是直写还是写回。
PCD:用来控制页表或页能否被缓存到cache中。
A:A=1表示指定页表或页被访问过,初始化时操作系统将其清0。
D:脏位,只在页表项中有意义。D=1表示被修改过,否则表示为被修改,操作系统将页面替换出主存时,无须将页面写入硬盘。
页目录项和页表项中的高20位是页表或页在主存中的首地址对应的页框号,即首地址的高20位。每个页表的起始位置都按4kb对齐。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB:翻译后备缓冲器,是在MMU中的一个关于PTE的小的缓存。TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。虚拟地址中用以访问TLB的组成部分如下。
TLB命中时的地址翻译步骤有:
1.CPU产生一个虚拟地址。
2.MMU从TLB中取出相应的PTE。
3.MMU将这个虚拟地址翻译成一个物理地址,并将它发送到高速缓存或主存。
4.高速缓存或主存将所请求的数据字返回给CPU。
当TLB不命中时,MMU必须从L1缓存中取出相应的PTE。新取出的PTE存放在TLB中,可能回覆盖一个已经存在的条目。
四级页表下的VA到PA的变换:下面是使用四级页表进行地址翻译的示意图。虚拟地址被划分位4个VPN和1个VPO。每个VPN都是一个到某一级页表的索引。36位VPN被划分为4个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
下图给出了在四级页表和三级cache下的物理内存访问示意图。四级页表下的虚拟地址到物理地址的转换已经有过阐述,接下来描述物理内存的访问。
图中的L1cache有64组,八路组相连,每块64字节。所以块偏移CO是6位,组索引CI是6位,剩下的40位为标记CT。现有物理地址52位,低6位是CO,CO的左边高6位是CI,剩余的是CT。根据组索引CI,定位到L1cache中的某一组,遍历这一组中的每一行,如果某一行的有效位为1且标记位等于CT,则命中,根据块偏移CO取出数据。如果未命中,则向下一级cache寻找数据。更新cache时,首先判断是否有空闲块。如果有,则写入这个块,否则根据替换算法驱逐一个块后再写入。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并允许包含了可执行文件hello中的程序,用hello程序有效地替代了当前程序。加载并允许hello需要以下一个步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域。为hello程序的代码、数据、bss和栈区域创建新的区域结构。
3.映射共享区域。如果hello与共享对象或目标链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器PC。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux根据需要换入代码和数据页面。下面是加载器如果映射用户地址空间的区域的示意图。
7.8 缺页故障与缺页中断处理
在异常控制流中学过,缺页异常是一种经典的故障。发生故障时,处理器将控制转移给故障处理程序。如果处理程序额能够修正这个错误的情况,它就将控制返回到引起故障的指令,重新执行。否则处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
一般的缺页情况如下:
CPU引用了VPi中的一个字,VPi并未缓存在物理内存中。地址翻译硬件从内存中读取PTEi,从有效位推断出VPi未被缓存,并且触发了一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序选择一个牺牲页,假设是VPj。如果VPj已经被修改了,那个内核就会将它复制回磁盘。无论那种情况,内核都会修改VPj的页表条目,反应出VPj不再缓存在主存中。接下来,内核从磁盘复制VPi到内存中的PPi,更新PTEi,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。现在,VPi已经缓存在主存中了,那么页命中页能由地址翻译硬件正常处理了。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。对于每个进程,内核维护者一个变量brk,指向堆的顶部。堆的示意图如下。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。一个已分配的块保持已分配状态,直到它被释放。分配器有两种基本风格,显式的和隐式的。
显式分配器,要求应用显式地释放任何已分配的块。如C标准库提供的malloc程序包显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。
隐式分配器,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。
一个实际的分配器需要考虑以下几个问题:
1.空闲块组织:如何记录空闲块?
2.放置:如何选择一个合适的空闲块来放置一个新分配的块?
3.分割:在将一个新分配的块放置到某个空闲块之后,如何处理这个空闲块中的剩余部分?
4.合并:如何处理一个刚刚被释放的块?
一种较为简单的叫隐式空闲链表的数据结构可以较好地解决这些问题。结构示意图如下:
每一个堆块内有一些字,每个字有4个字节。第一个字记录这个堆块的大小,以及是已分配的还是空闲的。这里介绍的堆块是双字对齐的,所以块大小一定为8的倍数,二进制的低第三位是0。所以用最低位来表示这个块是以分配的还是空闲的。有效载荷就是用户申请的空间,填充是不使用的,大小任意,填充可能是分配器策略的一部分,用来对付外部碎片,或者用它来满足对齐要求。
动态存储的分配管理主要包括以下几个操作:
1.放置已分配的块
当应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的,有首次适配、下一次适配、最佳适配等。
首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配:从上一次查询结束的地方开始搜索。最佳适配:检查每个空闲块,选择适合所需请求大小的最小空闲块。
2.分割空闲块
一旦分配器找到一个匹配的空闲块,就必须考虑分配这个空闲块中的多少空间。如果匹配不太友好,则分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块,剩下的部分变成一个新的空闲块。
例如,目前的堆情况如下图所示:
现在有一个3个字的分配请求,因为第一个空闲块空间不够,所以将第二个空闲块分割来分配。分配情况如下图,红色方框的位置即为新分配的块。第一个字保存这个分配块的信息,后三个字保存有效载荷。
3.获取额外的堆内存
如果分配器不能为请求块找到合适的空闲块,可以通过合并那些在内存中物理相邻的空闲块来创建一个更大的空闲块。如果这样还是不能生成一个足够大的块,则分配器会调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化为一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。
4.合并空闲块
当分配器释放一个已分配块石,可能有其他空闲块与这个新释放的空闲块相邻,如下图所示。
可以看到,有两个相邻的空闲块。此时如果请求一个4字的空闲块,分配器发现当前的空闲块无法满足要求,就会合并空闲块,将上图中两个相邻的空闲块合并成为一个大的空闲块。
有一中更加优化的数据结构能够在常数时间内进行合并。
相比于前面的隐式空闲链表结构,这种结构在块的尾部有一个头部的副本,这样无论是从当前的块向前还是向后合并,都可以检查前一个块或者是后一个块是否是空闲的。如果是,就将它的大小简单地加到当前块头部的大小上,使得这两个块在常数时间内被合并。
7.10本章小结
本章重点介绍了计算机中的存储,包括地址空间的分类,地址的变换规则,虚拟内存的原理,cache的工作,和动态内存的分配。虚拟内存存在于磁盘中,处理器产生一个虚拟地址,然后虚拟地址通过页表映射等相关规则被转化为物理地址,再通过cache和主存访问物理地址内保存的内容,返回给处理器。通过这一章的学习和总结,我对于计算机的存储方式、缓存、寻址更加理解了。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件。一个linux文件就是一个m个字节的序列。所有的IO设备都被模型化为文件,包括网络、磁盘、终端等。
设备管理:unix io接口。所有的IO设备都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许linux内核引出一个简单、低级的应用接口,成为unix IO,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix io接口:
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)、标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
改变当前文件的位置。对于每个打开的文件,内核保持着一个文件位置k,初始0.这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
Unix IO函数:
open()函数。进程通过调用open函数来打开一个已存在的文件或者创建一个新文件。函数原型:int open(char *filename, int flags, mode_t mode);open函数将filename转换成一个文件描述符,并且返回描述符字。返回的描述符总是在进程中当前没有的打开的最小描述符。flags参数指明了进程打算如何访问这个文件。
close()函数。进程通过调用close函数关闭一个打开的文件。函数原型:int close(int fd);若返回成功则为0,若出错则为-1。
read()函数和write()函数。read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。函数原型:ssize_t read(int fd, void *buf, size_t n);write函数从内存位置buf复制至多n个字节到描述符fd的当前位置。函数原型:ssize_t write(int fd, const void *buf, size_t n);若成功则返回写的字节数,若出错则返回-1。
lseek函数。通过调用lseek函数,应用程序能够显式地修改当前文件的位置。函数头文件和原型如下。
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
它能够调整读写的位置。若调用成功,则返回当前读写位置相对于文件开始位置的偏移量。若调用失败,则返回-1,并给errno设置错误号。
8.3 printf的实现分析
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;
}
fmt是一个指针,指向第一个const参数中的第一个元素。((char *)(&fmt)+4)表示的是…中的第一个参数的地址。接下来是vsprinff(buf,fmt,arg)。vsprintf函数如下:
int vsprintf(char *buf, const char fmt, va_list args)
{
char p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
vsprintf返回的是要打印出来的字符串的长度,作用是格式化,它接受确定输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出。
下一步是write(buf,i)。通过反汇编跟踪,发现这里是给几个寄存器传递了参数,然后一个int结束。这样的int表示要调用中断门了。通过中断门,来实现特定的系统服务。可以找到INT_VECTOR_SYS_CALL的实现:init_idt_desc(INT_VECTOR_SYS_CALL,DA_386TGate,sys_call,PRIVILEGE_USER);它表示通过系统来调用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
开头的一个call save,是为了保存中断前进程的状态。sys_call的功能就是显示格式化了的字符串。到这里,printf的底层实现就基本结束了。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar由宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾则返回-1(EOF),且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章着重介绍了Linux的IO设备管理方法,Unix IO接口及其函数,以及printf,getchar的实现和工作过程。这增强了对平时经常调用的一些函数的理解。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello.c编写完成后,要运行需要经过一下几个步骤:
1.hello.c经过预处理,生成文本文件hello.i。
2.hello.i经过编译,生成汇编文件hello.s。
3.hello.s经过汇编,生成二进制可重定位目标文件hello.o。
4.hello.o经过链接,生成可执行文件hello。
到这里,hello已经可以成功运行了。
在hello的运行过程中,涉及到调用fork函数生成子进程,并有execve函数加载并运行程序。在访存等过程中,涉及到存储管理,包括利用局部性的高速缓存cache和虚拟内存。涉及到输入输出时,又会利用IO管理,设备模拟化为文件等等。
总之,hello.c虽然只是一个简单的程序,但从它的编写完成到执行,再到终止,经过了一系列复杂的过程。通过学习计算机系统这门课,像编译、存储、进程并行并发等知识,尤其是编译那一部分,编译器的功能如此的强大,我深深地体会到了计算机科学的伟大。计算机科学当前发展了几十年,汇聚了各路科学家的智慧,在人类社会发挥这越来越重要的作用。在计算机系统、底层的领域一定还会有更加具有智慧的突破出现。
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.i 对hello.c进行预处理得到的文件
hello.s 对hello.i进行编译得到的文件
hello.o 对hello.s进行汇编得到的文件
hello 对hello.o进行链接得到的可执行文件
hello.elf hello.o的ELF格式
hello-run.elf hello的ELF格式
hello_objdump.txt 对hello.o进行反汇编得到的文件
hello-d-r.txt 对hello进行反汇编得到的文件
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Randal E. Bryant, David R. O’Hallaon. 深入理解计算机系统. 第三版. 北京市:机械工业出版社[M]. 2018: 1-737.
[2] 物理地址、虚拟地址(线性地址)、逻辑地址以及MMU的知识. CSDN https://blog.csdn.net/macrossdzh/article/details/5.
[3] lseek函数,lseek函数详细说明,函数原型和头文件,lseek函数的详细使用,补充命令(文件IO)[linux]。
https://blog.csdn.net/qq_43648751/article/details/104133348
[4] printf函数实现的深入剖析。https://www.cnblogs.com/pianist/p/3315801.html。
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.