本文是一些最近的学习内容
计算机科学与技术学院
2021年5月
摘 要
本文详细介绍了hello.c程序的一生——从源代码到经过预处理、编译、汇编、链接最终生成可执行目标文件hello,并对hello的反汇编进行分析,再通过在shell 中键入运行命令后,通过键盘输入信号,分析其对异常的处理,最后将其回收。
同时也展示了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的预处理结果解析... - 7 -
2.4 本章小结... - 8 -
第3章 编译... - 9 -
3.1 编译的概念与作用... - 9 -
3.2 在Ubuntu下编译的命令... - 9 -
3.3 Hello的编译结果解析... - 9 -
3.4 本章小结... - 17 -
第4章 汇编... - 18 -
4.1 汇编的概念与作用... - 18 -
4.2 在Ubuntu下汇编的命令... - 18 -
4.3 可重定位目标elf格式... - 18 -
4.4 Hello.o的结果解析... - 21 -
4.5 本章小结... - 23 -
第5章 链接... - 24 -
5.1 链接的概念与作用... - 24 -
5.2 在Ubuntu下链接的命令... - 24 -
5.3 可执行目标文件hello的格式... - 24 -
5.4 hello的虚拟地址空间... - 27 -
5.5 链接的重定位过程分析... - 28 -
5.6 hello的执行流程... - 30 -
5.7 Hello的动态链接分析... - 30 -
5.8 本章小结... - 31 -
第6章 hello进程管理... - 32 -
6.1 进程的概念与作用... - 32 -
6.2 简述壳Shell-bash的作用与处理流程... - 32 -
6.3 Hello的fork进程创建过程... - 32 -
6.4 Hello的execve过程... - 33 -
6.5 Hello的进程执行... - 33 -
6.6 hello的异常与信号处理... - 35 -
6.7本章小结... - 37 -
第7章 hello的存储管理... - 38 -
7.1 hello的存储器地址空间... - 38 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 38 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 39 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 42 -
7.5 三级Cache支持下的物理内存访问... - 43 -
7.6 hello进程fork时的内存映射... - 44 -
7.7 hello进程execve时的内存映射... - 44 -
7.8 缺页故障与缺页中断处理... - 45 -
7.9动态存储分配管理... - 46 -
7.10本章小结... - 47 -
第8章 hello的IO管理... - 48 -
8.1 Linux的IO设备管理方法... - 48 -
8.2 简述Unix IO接口及其函数... - 48 -
8.3 printf的实现分析... - 49 -
8.4 getchar的实现分析... - 51 -
8.5本章小结... - 52 -
结论... - 53 -
附件... - 55 -
参考文献... - 56 -
第1章 概述
1.1 Hello简介
Hello 的 P2P(From Program to Process)的过程为:程序员从键盘输入hello.c 文件,一个 hello 的 C 语言雏形诞生,然后经过预处理器、汇编器、编译器、链接器的一系列处理,一个完美的生命——hello 可执行文件诞生了(Program)。
之后,程序员在 shell 中输入./hello 运行 hello可执行程序,shell 识别出这是一个外部命令,先调用 fork 函数创建了一个新的子进程(Process),然后调用 execve函数在新的子进程中加载并运行 hello,运行 hello 还需要 CPU 为 hello 分配内存、时间片,使得 hello 看似独享 CPU 资源。在 hello 运行的过程中,CPU 要访问相关数据需要 MMU 的虚拟地址到物理地址的转化,其中 TLB 和四级页表为提高地址翻译的速度做出了巨大贡献,得到物理地址后三级 Cache 又帮助 CPU 快速得到需要的字节。系统的进程管理帮助 hello 切换上下文、shell 的信号处理程序使得 hello 在运行过程中可以处理各种信号,当程序员主动地按下 Ctrl+Z 或者 hello 运行到 return 0;时,hello 所在进程将被杀死,shell 会回收它的僵死进程。以上就是 hello 的 P2P(From Program to Process)、020(From Zero to Zero)的全过程。
1.2 环境与工具
1.2.1 硬件环境
Intel(R) Core(TM) i7-10750H CPU;2.60GHz16G RAM;512 + 1024GHD Disk
1.2.2 软件环境
Windows10 64位;Ubuntu 18.04 LTS 64位;
1.2.3 工具
Visual Studio 2019 64位;vim, gedit,gcc, readelf, objdump, hexedit, edb
1.3 中间结果
文件名 |
文件说明 |
hello.c |
源代码 |
hello.i |
预处理后的文件 |
hello.s |
编译之后的汇编文件 |
hello.o |
汇编之后的可重定位目标文件 |
hello |
链接之后的可执行目标文件 |
hello1.elf |
hello.o 的 ELF格式 |
hello1_dis |
hello.o 的反汇编代码 |
hello.elf |
hello的ELF 格式 |
hello_dis |
hello 的反汇编代码 |
1.4 本章小结
本章介绍了 hello 从编译生成、到执行、到终止以及最后被回收的全过程,从整体上介绍了 hello 的一生,并且列出了本的软硬件环境以及开发工具,最后列出了从 hello.c 到 hello 可执行程序的过程中产生的中间文件。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
程序设计领域中,预处理又称预编译,一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。预处理会根据以字符#开头的命令(即头文件/define等),修改原始的c程序。典型地,由预处理器(preprocessor,简记为cpp)对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(对C/C++来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
2.1.2 预处理的主要作用
1) 将源文件中以”include”格式包含的文件复制到编译的源文件中。
2) 用实际值替换用“#define”定义的字符串。
3) 根据“#if”后面的条件决定需要编译的代码。
2.2在Ubuntu下预处理的命令
图 2.1 预处理命令
2.3 Hello的预处理结果解析
图 2.2 hello.i结果
程序扩展为3065行,hello源程序出现在3047行,#include <stdio.h> #include <stdlib.h> #include <unistd.h>三个头文件指令消失,取而代之的是一大段代码,描述的是所需库在计算机中的位置,方便下一步翻译成汇编语言。
2.3.1文件包含
#include指令是一个将所有声明放在一起的好方法,它保证所有的源文件都具有相同的定义与变量(函数)声明。如果某个包含文件的内容发生了变化,那么所以依赖于该包含文件的源文件都必须重新编译。
#include "file name"
#include <file name>
#include 记号序列
在源文件中,上面行将被替换为由文件名指定的文件内容。
如果文件名用引号引起来,则在源文件所在位置查找该文件;如果在该位置没有找到文件或如果文件名是尖括号括起来,则将根据相应的规则查找该文件,这个规则同具体实现有关。
2.3.2宏替换
预处理器把该标识符后续出现的各个实例用给定的记号序列替换。
#define 标识符 记号序列
#define 标识符(标识符表) 记号序列
第二次用#define指令定义同一标识符是错误的,除非第二次定义中的标记序列与第一次相同。通常情况下,#define指令占一行,但也可以把一个较长的宏定义分成若干行,这时需要在待续的行尾加上一个反斜杠\。 替换只对记号进行,对括在引号中的字符串不起作用。
如果在替换文本中,参数名以#作为前缀则结果将被扩展为由实际参数替换该参数的带引号的字符串。
预处理运算符##为宏扩展提供一种连接实际参数的手段。如果替换文本中的参数与##相邻,则该参数将被实际参数替换,##与前后的空白符将被删除,并对替换后的结果进行重新扫描。
2.3.3条件包含
用于条件语句可以对预处理本身进行控制。
#if语句对其中的常量整形表达式进行求值,若该表达式的值不等于0,则包含其后的各行,直到遇到#endif、#elif或#else语句为止。如:
#if !defined(HDR)
#define HDR
...
#endif
2.4 本章小结
本章介绍了预处理的具体操作。预处理是hello.c源文件在编译系统里走过的第一程,它主要是拓展源代码,插入所有#include指定的文件,拓展#define声明。经过这个阶段,我们得到了一个中间文件hello.i,它将是下一个阶段编译器(ccl)处理的对象。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译是编译器(cc1)将文本文件hello.i编译成文本文件hello.s过程,这一过程把高级语言变成计算机可以识别的汇编语言。而汇编语言程序中的每条语句都以一种标准的文本格式确切的描述一条低级机器语言指令。
3.1.2编译的作用
1) 扫描(词法分析
2) 语法分析
3) 语义分析
4) 源代码优化(中间语言生成)
5) 代码生成,目标代码优化。
3.2 在Ubuntu下编译的命令
图 3.1 编译命令
3.3 Hello的编译结果解析
所有以‘.’开头的行都是指导汇编器和链接器工作的伪指令
图3.2伪指令部分
3.3.1 数据
1.常量
常量大多是以立即数的形式出现在汇编代码hello.s中, 如:if(argc!=4)中的4,
图3.3 立即数4的存储
再比如for(i=0;i<8;i++)中的8,编译时编译器把i<8翻译为i<=7但这也是用立即数的形式进行存储的
图3.4 立即数7的存储
2.变量
变量分为全局变量、静态变量、局部变量。已初始化的全局变量和静态变量存放在.data节,未初始化的全局变量和静态变量存放在.bss节,而局部变量存放在栈中管理。
hello.c程序中,没有全局变量和静态变量,所以会发现在hello.s的最开始伪指令中没有.data和.bss。但用到了.rodata只读数据节,因为这里面存放了printf的格式串。
例如:printf("用法: Hello 学号 姓名 秒数!\n"),它的汇编指令为movl $.LC0, %edi 和call puts,可以看到.LC0的string正是我们要输出的字符串,其中汉字转化成了相应的编码;
同理还有printf("Hello %s %s\n",argv[1],argv[2]),汇编指令为movl $.LC1, %esi、movl $1, %edi、movl $0, %eax和call __printf_chk,因为涉及到参数传递,所以过程稍微复杂些,不过此处我们只关心printf的格式串,它也在.rodata中。
图 3.5 格式串的存储
for(i=0;i<8;i++)中的i是一个局部变量,编译器将它放在了寄存器%ebx中。首先将它初始化为0,使用cmpl和addl指令可实现对其的操作。
图 3.6 局部变量i的存储
3.3.2 赋值操作
hello.c中赋值表达式i=0,它通过movl指令实现。
mov是一类指令,通用形式为:
图 3.7 mov指令表
除表中展示的之外,常用的操作还有movz(零拓展传送指令)和movs(符号拓展传送指令)。
3.3.3 算术操作
在hello.s中我们用到了addl $1, %ebx,用以实现i++操作。通用的整数操作指令如下表:
图 3.8 整数操作指令表
3.3.4 关系操作
常用的关系操作指令有cmp和test,具体使用规则如下:
图 3.9 关系操作指令表
它们只设置条件码而不改变任何其他寄存器。在AT&T汇编指令集中cmp指令根据后操作与前操作数数之差来设置条件码。TEST指令的行为与AND指令一样,它们只设置条件码而不改变目的寄存器的值。
在hello.s中我们使用了cmp指令来判断argc!=4和i<8,分别为cmpl $4, %edi和cmpl $7, %ebx。
3.3.5 控制转移
在这一部分经常用到jump指令,使用规则如下:
图 3.10 jump操作指令表
If语句或者switch语句。在hello.c中是if(argc!=4)
图 3.11 hello.c中if语句
其实现如下:
图 3.12 if语句对应的汇编指令
argc是main的第一个参数,所以在%edi中。首先使用cmpl指令比较argc和4的大小,设置条件码,然后jne根据条件码比较argc与4是否相等,并根据结果选择是否进行跳转:若不等,则跳转到.L6;若相等,则继续运行紧接着的下一条指令。
for循环。在hello.c中是
图 3.12 helo.c中的for语句
其实现如下:
图 3.13 for语句的汇编指令
首先是对i赋初值为0,然后跳转到循环判断表达式测试是否满足循环条件,如果是的话则跳转到循环表达式。汇编语言等价的goto版C语言如下:
- int i=0;
- goto test;
- loop:
- printf("Hello %s %s\n",argv[1],argv[2]);
- sleep(atoi(argv[3]));
- i++;
- test:
- if (i <=7)
- goto loop;
3.3.6 函数调用
C语言过程调用机制的一个关键特性在于使用了栈数据结构提供的后进先出的内存管理原则。通用的栈帧结构如下:
图 3.14 栈帧的结构
函数P调用Q包括下面一个或多个机制:
传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。该过程使用call和ret指令实现。
传递数据。P必须能够向Q提供一个或多个参数, Q必须能够向P返回一个值。P可以通过寄存器向Q传递6个参数,分别存在%rdi、%rsi、%rdx、%rcx、%r8、%r9中,超出6个的部分就要通过栈来传递。
分配和释放内存。在开始时, Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
下面我们具体分析hello.s中的函数调用。首先分析简单的函数调用:
1、printf("用法: Hello 学号 姓名 秒数!\n"),对应的汇编语言如下:
直接将唯一的参数.LC0传递到%edi中,然后调用puts函数;
2、exit(1),对应的汇编语言如下:
直接将唯一的参数1传递到%edi中,然后调用exit函数;
下面分析几个稍微复杂的:
3、atoi(argv[3]),对应的汇编语言如下:
我们发现它传递了3个参数,第一个是24(%rbp),也就是argv[3],我们根据前面的movq %rsi, %rbp指令(将main的第2个参数argv[]的首地址传递给了%rbp)和main函数的栈帧可以得到这个结论,如下图所示:
图 3.15 参数列表在栈中位置
调用strtol函数还传递了另外两个参数0和10,由于超出所学范围所以在此不再深究,只需知道传递了3个参数便可。
4、printf("Hello %s %s\n",argv[1],argv[2]) ,对应的汇编语言如下:
它传递了4个参数,第一个是%edi中的1,第2个时%esi中的.LC1,也就是之前分析过的在.rodata中的格式串,第3个是argv[1],通过8(%rbp)计算得到(见上图),第4个是argv[2],通过16(%rbp)计算得到(见上图).
除了传递上述参数之外,函数在调用时通过call语句,将返回地址压入栈中,并将PC设置为调用函数的起始地址;结束时通过%rax作为返回值(如果需要的话),并通过ret指令从栈中弹出返回地址,并将PC设为返回地址。
3.3.7 数组操作
C语言中的数组是一种将标量数据聚集成更大数据类型的方式。对于数据类型T和整型常数N,声明数组A[N],起始位置表示为x0,表明它在内存中分配一个L·N字节的连续区域,这里L是数据类型T的大小;其次,它引入了标识符A,可以用A来作为指向数组开头的指针,这个指针的值就是x0,可以用0~N-1的整数索引来访间该数组元素。数组元素会被存放在地址为x0+L* i上的地方。
在本例中的数组是argv,这是一个指针数组,每个数组元素是一个指向参数字符串的指针。我们来看下如何调用argv的一个数组元素。C语句printf("Hello %s %s\n",argv[1],argv[2])的汇编代码为:
其中%rdx和%rcx分别向printf传递参数argv[1]和argv[2]
图 3.16 参数列表在栈中位置
3.4 本章小结
本章介绍了编译的概念及其过程,分析了编译得到的文件,介绍汇编代码如何实现变量、常量、传递参数以及分支和循环。编译是将hello.i编译成汇编文本文件hello.s。汇编代码是机器代码的文本表示,包含了程序中的每一条指令。阅读和理解汇编代码是一项很重要的技能,通过理解这些汇编代码,我们能够理解编译器的优化能力,并分析代码中隐含的低效率。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
汇编是通过汇编器,把汇编语言中的指令打包成一种具有可重定位目标程序(relocatable object program)格式的机器语言的过程。
4.1.2汇编的作用
汇编器(as)将hello.s翻译成机器指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中。hello.o中就包含了hello.c中可被机器解读的机器码
4.2 在Ubuntu下汇编的命令
图 4.1 将hello.s汇编出hello.o
4.3 可重定位目标elf格式
图 4.2 得到elf格式文件
先输入如上指令得到elf文件。接下来我们对得到的文件进行具体分析:
4.3.1 ELF头
首先是ELF头,ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小,目标文件的类型(可重定位、可执行或者是共享的)、机器类型(如x86-64)、节头部表的文件偏移、以及节头部表中条目的大小和数量。总而言之就是包含了代码的各种环境信息,便于我们对其分析。
图 4.3 elf头
4.3.2节头目表
此部分列出了hello.o中的各个节的名称、类型、地址、偏移量、大小等信息。具体内如下:
图 4.4 节头目表
由于是可重定位目标文件,所以每个节都从0开始,用于重定位;.text段是可执行的,但是不能写入;.data段和.rodata段都不可执行且.rodata段不可写;.bss段大小为0。
4.3.3 重定位节
重定位节记录了各段引用的符号相关信息,在链接时,需要通过重定位节对这些位置的地址进行重定位。链接器会通过重定位条目的类型判断如何计算(绝对寻址/相对寻址)地址值并使用偏移量等信息计算出正确的地址。具体如下:
图 4.5 重定位节
相关属性:
偏移量:通常对应于一些需要重定向的程序代码所在.text或.data节中的偏移位置。
信息:重定位到的目标在符号表中的偏移量
类型:代表重定位的类型,与信息相互对应。
符号值:符号所存的值
名称:重定向到的目标的名称
加数:用来作为计算重复或定位文件位置的一个辅助运算信息,共计约占8个字节。
本程序需要重定位的符号有:.rodata,puts,exit,printf,atoi,sleep,getchar及.text等。该程序的重定位类型仅有R_X86_64_PC32(PC相对寻址)和R_X86_64_PLT32(使用PLT表寻址)两种,而未出现R_X86_64_32(绝对寻址)。
4.4 Hello.o的结果解析
图 4.6 生成反汇编指令
首先先输入如下指令利用odjdump得到反汇编文件,hello.o与hello的反汇编代码截图如下:
图 4.7 hello反汇编(上)hello.o反汇编(下)
比较分析hello.o与hello.s,二者总体的汇编代码思路相同,但确实有一些细微的差异:
(1) 分支控制转移不同:
对于跳转语句跳转的位置,hello.s中是.L2、.LC1等代码块的名称,而反汇编代码中跳转指令跳转的位置是相对于main函数起始位置偏移的地址(相对地址);
(2) 函数调用表示不同:
hello.s中,call指令使用的是函数名,而反汇编代码中call指令使用的是待链接器重定位的相对偏移地址,这些调用只有在链接之后才能确定运行时的实际地址,因此在.rela.text节中为其添加了重定位条目;
(3) 数的表示不同:
hello.s中的操作数均为十进制,而hello.o反汇编代码中的操作数被转换成十六进制;
(4) hello.s中的全局变量、printf字符串等符号被替换成了待重定位的地址;
4.5 本章小结
了解汇编的概念和作用,汇编得到.o文件,然后分析了可重定位目标elf格式,然后使用objdump进行反汇编并与.s文件进行比较,更加深入地理解机器语言与汇编语言的关系。
本章介绍了汇编及其过程。汇编器将汇编语言翻译成机器语言指令,把这些指令打包可重定位目标程序,并将结果保存在hello.o中。hello.o文件是一个二进制文件,它包含的是函数main的指令编码。多个可重定位目标文件可以在链接时合并在一起,创建一个可执行的目标文件。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时、加载时、运行时。例如: hello程序调用了printf函数,它是每个C编译器都提供的标准库中的一个函数。printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。链接器(ld)就负责处理这种合并。结果就得到hello文件,它是一个可执行目标文件
链接使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
使用如下指令把必要的外部库连接到hello,并生成可执行文件。
图 5.1 链接命令
5.3 可执行目标文件hello的格式
使用readelf -a hello > hello.elf 命令生成hello 程序的ELF 格式文件。
图 5.2 生成ELF格式文件指令
接下来对其进行详细分析:
Elf头:表示了可执行程序hello的节的基本信息
图 5.3 ELF头
节头部表:表示各段的基本信息(起始地址、大小等)
程序头表示各个程序的类型、偏移量、大小、虚拟地址、物理地址和读写权限等。
重定位节:用于链接时重定位可重定位文件,合成可执行程序。
符号表:存储可重定位文件的中需要重定位的符号。
5.4 hello的虚拟地址空间
在edb中加载hello,edb完整界面如下图:
图 5.4 edb加载hello截图
此时Data Dump窗口中显示的就是hello的虚拟空间内容,如下图(显示范围为0x401000至0x402000):
图 5.5 Data Dump窗口
通过与Symbols窗口对照,可以发现各段均一一对应,如下图(未全部展示):
401030:puts
401040:printf
401050:getchar
401060:atoi
401070:exit
401080:sleep
图5.6 符号表与Data Dump的对应
这些symbols和虚拟内存的对应关系说明了各段的虚拟地址与节头部表的一一对应。
5.5 链接的重定位过程分析
使用指令jdump -d -r hello > hello_dis得到hello的反汇编如下:
图5.7 生成hello反汇编指令
图5.8 hello与hello.o反汇编对比
通过对比发现,整体上来看,hello的反汇编代码比hello.o的反汇编代码多了一些节(如.init, .plt, .plt.sec等)。同时hello中相比hello.o多了一些例如_init、puts@plt、__printf_chk@plt等许多函数;还有就是:hello.o中的重定向条目,在hello中也替换为了也有了实际的地质取代,详细如下:
图5.9 hello与hello.o反汇编函数跳转对比
5.6 hello的执行流程
使用edb单步调试运行程序,观察其调用的函数,这里可以发现在调用main之前主要进行了初始化的工作调用了_init,在这个函数之后动态链接的重定位工作已经完成,我们可以看到在这个函数的调用之后是一系列在这个程序中所用到的库函数(printf,exit,atoi等等)这些函数实际上在代码段并不占用实际的空间只是一个占位的符号,实际上他们的内容在共享区(高地址)处。之后调用了_start这个就是起始的地址,准备开始执行main的内容,main函数内部所调用的函数在第三章已经进行了充分的分析这里略过main内部的函数,在执行main之后还会执行__libc_csu_init 、__libc_csu_fini 、_fini 最终这个程序才结束。
下面列出了各个函数的名称与地址(有些没有具体进入分析,只体现了hello的执行的一个大概的流程)
- _init <0x00000000004004e0>
- puts@plt <0x0000000000400510>
- printf@plt <0x0000000000400520>
- getchar@plt <0x0000000000400530>
- atoi@plt <0x0000000000400540>
- exit@plt <0x0000000000400550>
- sleep@plt <0x0000000000400560>
- _start <0x0000000000400570>
- _dl_relocate_static_pie <0x00000000004005a0>
- deregister_tm_clones <0x00000000004005b0>
- register_tm_clones <0x00000000004005e0>
- __do_global_dtors_aux <0x0000000000400620>
- frame_dummy <0x0000000000400650>
- main <0x0000000000400657>
- __libc_csu_init <0x00000000004006f0>
- __libc_csu_fini <0x0000000000400760>
- _fini <0x0000000000400764>
5.7 Hello的动态链接分析
对于动态共享链接库中 PIC 函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,GNU 编译系统使用延迟绑定技术,将过程地址的绑定推迟到第一次调用该过程时。使用延迟绑定的动机是对于一个像 libc.so 这样的共享库输出的成百上千个函数中,一个典型的应用程序只会使用其中很少的一部分。把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接的内存引用。延迟绑定是通过两个数据结构的交互来实现的,这两个数据结构是 GOT(全局偏移量表)和 PLT(过程链接表)。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的 GOT 和 PLT。GOT 是数据段的一部分,PLT 是代码段的一部分。下图介绍了 GOT 和 PLT 交互的一个例子。只需注意:GOT 和 PLT 联合使用时,GOT[0]和 GOT[1]包含动态连接器在解析函数地址时会使用的信息。GOT[2]是动态连接器在 ld-linux.so 模块中的入口点。
接下来观察 dl_init 前后动态链接项目的变化。.got.plt 节的起始地址是 0x601000,在 DataDump 中找到该位置,
图5.10 edb中got.plt节位置
使用 edb 执行至 dl_init,按 F8,发现地址 0x601000 后发生了变化:
图5.11 edb调试程序
可以看到 dl_init 后出现了两个地址0x7f27f6a3el70和0x7f27f682c750,这便是 GOT[1]和 GOT[2]。
5.8 本章小结
本章介绍了链接及其过程。链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。对hello.o进行链接得到hello文件,并对hello进行分析,与hello.o的反汇编代码进行比较,可以帮助更好地掌握重定位过程。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程是一个执行中的程序的实例,同时也是系统进行资源分配和调度的基本单位。一般情况下,包括文本区域、数据区域和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
6.1.2 进程的作用
给应用程序提供两个关键抽象:一个独立的逻辑流,它提供一个假象,好像我们的程序独占地使用处理器;一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
6.2.2 Shell-bash的作用
shell是一个命令解释器,它解释用户输入的命令并把它们送到内核,用于用户和系统的交互。
1) 终端进程读取用户由键盘输入的命令行。
2) 分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
3) 检查argv[0]是否是一个内置的shell命令
4) 如果不是内部命令,调用fork( )创建新进程/子进程
5) 在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
6) 如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait)等待作业终止后返回。
7) 如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程
shell判断出不是内置命令后,加载可执行文件hello,通过fork创建子进程,子进程得到一份与父进程用户级虚拟地址空间相同的副本,还获得与父进程打开的文件描述符相同的副本,子进程与父进程的PID不同。fork被调用一次,但返回两次,父进程中返回子进程的PID,子进程返回0。
6.4 Hello的execve过程
fork 之后,shell 在子进程中调用 execve 函数,在当前进程的上下文中加载并运行 hello 程序,execve 调用驻留在内存中的被称为启动加载器的操作系统代码来执行 hello 程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射(具体的映射机制后面会谈到)到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容,然后跳转到_start,_start 函数调用系统启动函数__libc_start_main 来初始化环境,调用用户层中hello 的 main 函数,并在需要的时候将控制返回给内核。
6.5 Hello的进程执行
在操作系统中,每一个时刻通常有许多程序在进行,但是我们通常会认为每一个进程都独立占用CPU内存以一些其他资源,如果单步调试我们的程序可以发现在执行时一系列的(PC)程序计数器的值,这个PC值的序列就是逻辑控制流,事实上,多个程序在计算机内部执行时,采用并行的方式,他们的执行是交错的,像下图每个程序都交错运行一小会儿,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
图 6.1 进程的并发
操作系统内核使用一中称为上下文切换的较高层形式的异常控制流来实现多任务:内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程一打开文件的信息的文件表。上下文切换的流程是:1.保存当前进程的上下文。2.恢复某个先前被抢占的进程被保存的上下文。3.将控制传递给这个新恢复的进程。
图 6.2 进程的切换
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常使用某个控制寄存器的一个模式位提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
接下来分析 hello 的进程调度,hello的一个上下文切换是调用sleep函数时,hello 显式地请求休眠,控制转移给另一个进程,此时计时器开始计时,当计时器到达argv[3],即1s时,它会产生一个中断信号,中断当前正在进行的进程,进行上下文切换,恢复 hello 在休眠前的上下文信息,控制权回到 hello 继续执行。当循环结束后,hello 调用 getchar 函数,之前 hello 运行在用户模式下,在调用 getchar 时进入内核模式,内核中的陷阱处理程序请求来自键盘缓冲区的 DMA传输,并执行上下文切换,并把控制转移给其他进程。当完成键盘缓冲区到内存的数据传输后,引发一个中断信号,此时内核从其他进程切换回 hello 进程,然后 hello执行 return,进程终止。
6.6 hello的异常与信号处理
先在终端输入一条指令,运行可执行程序。同时在执行过程中随机按下键盘 结果如下图所示:
图 6.3 程序执行的结果
分析得到,如果在hello程序运行过程中有输入,shell会把其当为命令放在缓冲区,由于是随机输入的,因此会提示未找到命令。
重新运行程序并在其运行过程中按下Ctrl+c,输入fg指令查看前台任务结果如下:
图 6.4 程序执行的结果
分析得到,使用Ctrl+c,进程被终止所以输入fg后提示当前无前台任务。
重新运行程序并在其运行过程中按下Ctrl+z,输入jobs指令查看任务,之后输入fg使其重新作为前台任务执行,结果如下:
图 6.5 程序执行的结果
分析得到,使用Ctrl+z,任务被挂起到后台,但是jobs仍存在,使用fg调到前台之后继续运行任务,直至正常结束运行。
重新运行程序,在中间按下Ctrl+z,键入ps指令查看任务,之后:输入kill命令+其进程号,结果如下:
图 6.6 程序执行的结果
分析得到使用Ctrl+Z,任务被挂起到后台。jobs仍存在,使用kill命令可以将某一个具体的进程杀死。
下面对hello进程被终止前后的进程树进行分析,在终端输入pstree指令进行查看,由于进程树描绘了从开机开始所有运行的进程之间的关系,在这里我们只对我们感兴趣的部分,即与hello进程相关的叶节点进行分析,结果如下:
图 6.7 终止hello进程前相关部分pstree的结构
图 6.8 终止hello进程后相关部分pstree的结构
分析得到,将hello删除之后,进程树中的叶节点hello被移除。
6.7本章小结
本章介绍了hello的进程管理;用于信号处理和hello进程在内核和前端中反复跳跃运行的上下文机制;以及用户通过shell和操作系统交互的方法,即向内核提出请求,shell通过fork函数和execve函数来运行可执行文件。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址(Logical Address)是指由程式产生的和段相关的偏移地址部分。表示为 [段标识符:段内偏移量]。
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
虚拟地址(Virtual Address)虚拟内存为每个程序提供了一个大的、一致的和私有的地址空间。其每个字节对应的地址成为虚拟地址。虚拟地址包括 VPO(虚拟页面偏移量)、VPN(虚拟页号)、TLBI(TLB 索引)、TLBT(TLB 标记)。
物理地址(physical address)用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相相应。是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成:段标识符和段内偏移量。在保护模式下:以段描述符作为下标,到 GDT/LDT 表查表可获得段地址,
段内偏移量是在链接后就已经得到的 32 位地址,因此要想由逻辑地址得到线性地址,还需要根据逻辑地址的前 16 位获得段地址,这 16 位存放在段寄存器中。
段寄存器(16 位),用于存放段选择符:
CS(Code Segment):代码段寄存器;DS(Data Segment):数据段寄存器;
SS(Stack Segment):堆栈段寄存器;ES(Extra Segment):附加段寄存器。
段寄存器是因为对内存的分段管理而设置的。当一个程序要执行时,就要决定程序代码、数据和堆栈各要用到内存的哪些位置,通过设定段寄存器CS,DS,SS来指向这些起始位置。通常是将DS固定,而根据需要修改CS。
接下来看一下段选择符中字段的含义:
图7.1段选择符的字段
其中 CS 寄存器中的 RPL 字段表示了CPU 的当前特权级
TI=0,选择全局描述符表(GDT);TI=1,选择局部描述符表(LDT);RPL=00 为第 0 级,是最高级的内核态;RPL=11 为第 3 级,则是最低级的用户态。高 13 位-8K 个索引用来确定当前使用的段描述符在描述符表中的位置。
有了以上的介绍就可以对逻辑地址进行转换了,首先根据段选择符的 TI 部分判断需要用到的段选择符表是全局描述符表还是局部描述符表,随后根据段选择符的高 13 位的索引(描述符表偏移)到对应的描述符表中找到对应的偏移量的段描述符,从中取出 32 位的段基址地址,将 32 位的段基址地址与 32 位的段内偏移量相加得到 32 位的线性地址。简单来说线性地址=基地址+偏移量。下图展示了逻辑地址到线性地址的转化过程:
图7.2 逻辑地址到线性地址
7.3 Hello的线性地址到物理地址的变换-页式管理
7.3.1虚拟地址的存储
Linux下,虚拟地址到物理地址的转化与翻译是依靠页式管理来实现的,虚拟内存作为内存管理的工具。概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。磁盘上数组的内容被缓存在物理内存中(DRAM cache)这些内存块被称为页 (每个页面的大小为P = 2p字节)。
7.3.2页式管理机制
而分页机制的作用就是通过将虚拟和物理内存分页,并且通过MMU建立起相应的映射关系,可以充分利用内存资源,便于管理。一般来说一个页面的标准大小是4KB,有时可以达到4MB。而且虚拟页面作为磁盘内容的缓存,有以下的特点:DRAM缓存为全相联,任何虚拟页都可以放置在任何物理页中需要一个更大的映射函数,不同于硬件对SRAM缓存更复杂精密的替换算法太复杂且无限制以致无法在硬件上实现DRAM缓存总是使用写回,而不是直写。
虚拟页面的集合被分为三个不相交的子集:已缓存、未缓存和未分配。
图7.3虚拟内存到内存的映射
页表实现从虚拟页到物理页的映射,依靠的是页表,页表是一个页表条目的数组,将虚拟页地址映射到物理页地址。这个页表是常驻于主存中的,便于查询。
7.3.3 线性地址(虚拟地址)到物理地址的转换
先用hello进程为例,进程执行时,CPU中的页表基址寄存器指向hello进程的页表,当hello进程访问其虚拟空间内的指令、数据等内容时,CPU芯片上的MMU(内存管理单元)会将对应的线性地址变换为物理地址以进行寻址访问。
n位的线性地址包含两部分:一个p位的虚拟页面偏移和一个(n-p)位的虚拟页号。MMU利用虚拟页号来选择适当的PTE(页表项),若PTE有效位为1,则说明其后内容为物理页号称为命中,否则缺页。
之后针对命中和缺页有不同的处理机制:
命中时的步骤如下:
1) 处理器生成一个虚拟地址,并把它传送给MMU;
2) MMU生成PTE地址,并从高速缓存/主存请求得到它;
3) 高速缓存/主存向MMU返回PTE;
4) MMU构造物理地址,并把它传送给高速缓存/主存;
5) 高速缓存/主存返回所请求的数据字给处理器
图7.4页面命中
发生缺页异常时的步骤如下:
1) 处理器生成一个虚拟地址,并把它传送给MMU;
2) MMU生成PTE地址,并从高速缓存/主存请求得到它;
3) 高速缓存/主存向MMU返回PTE;
4) PTE中的有效位是零,所以MMU触发了一次异常,传给CPU中的控制到操作系统内核中的缺页异常处理程序;
5) 缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘;
6) 缺页处理程序页面调入新的页面,并更新内存中的PTE;
7) 缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,在MMU执行了图b中的步骤之后,主存就会将所请求字返回给处理器。
图7.5缺页异常
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1TLB:
每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降到1或2个周期。然而,许多系统都试图消除即使是这样小的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。TLB的存在加快了PTE的读取。
7.4.2多级页表:
多级页表为层次结构,用于压缩页表。这种方法从两个方面减少了内存要求。第一,如果一级页表中的一个PTE是空的,那么相应的二级页表就根本不会存在;第二,只有一级页表才需要总是在主存中,虚拟内存系统可以在需要时创建、页面调出或调入二级页表,最经常使用的二级页表才缓存在主存中,减少了主存的压力。现在常见的core i7 使用的就是四级页表
7.4.3VA到PA的变换:
对于四级页表,虚拟地址(VA)被划分为4个VPN和1个VPO。每个VPN i都是一个到第i级页表的索引。对于前3级页表,每级页表中的每个PTE都指向下一级某个页表的基址。最后一级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE。和只有一级的页表结构一样,PPO和VPO是相同的。图示如下:
图7.6 VA到PA的变换示意图
7.5 三级Cache支持下的物理内存访问
高速缓存存储器(Cache)是设备中速度位于寄存器和主存之间的存在,它缓解了寄存器存储量有限的问题,同时具有良好的读写速度。它的组织结构如下:
图 7.7 Cache的组织结构
有了Cache的支持,对物理内存的访问也变得更加高效。要访问时首先,根据物理地址的 s 位组索引索引到 L1 Cache中的某个组,然后在该组中查找是否有某一行的标记等于物理地址的标记并且该行的有效位为 1,若有,则说明命中,从这一行对应物理地址 b 位块偏移的位置取出一个字节,若不满足上面的条件,则说明不命中,需要继续访问下一级 Cache,访问的原理与 L1 相同,若是三级 Cache 都没有要访问的数据,则需要访问内存,从内存中取出数据并放入Cache。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种必要的数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的页面标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要以下4个步骤:
1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的 区域结构。
2.映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构, 所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3.映射共享区域,hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到 这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
图 7.8 execve的内存映射
7.8 缺页故障与缺页中断处理
DRAM缓存不命中称为缺页,即虚拟内存中的字不在物理内存中。CPU引用了虚拟页的一个字,地址翻译硬件从内存中读取了该虚拟页对应的页表条目,从有效位推断出该页未被缓存,这样就触发了一个缺页异常,缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,把要缓存的页缓存到牺牲 页的位置。如果这个牺牲页被修改过,就把它交换出去。当缺页处理程序返回时, CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA。下图对VP3的引用不命中,从而触发缺页。
图 7.9 引发缺页异常
缺页之后,缺页处理程序选择VP4作为牺牲页,并从磁盘上用VP3的副本取代它。在缺页处理程序重新启动导致缺页的指令之后,该指令将从内存中正常地读取字,而不会再产生异常
图 7.10 缺页异常的处理
7.9动态存储分配管理
7.9.1动态内存分配管理及分配器的定义:
动态内存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。
分配器将堆视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
7.9.2分配器的分类:
分配器有两种风格——显式分配器和隐式分配器。
C语言中的malloc程序包是一种显式分配器。显式分配器必须在一些相当严格的约束条件下工作:①处理任意请求序列;②立即响应请求;③只使用堆;④对齐块(对齐要求);⑤不修改已分配的块。在以上限制条件下,分配器要最大化吞吐率和内存使用率。下面介绍几种常见的放置策略:
n 首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块。
n 下一次适配:类似于首次适配,但从上一次查找结束的地方开始搜索。
n 最佳适配:选择所有空闲块中适合所需请求大小的最小空闲块。
7.9.3常见组织内存块的方法:
(1) 隐式空闲链表:空闲块通过头部中大小字段隐含连接,可添加边界标记提高合并空闲块的速度。
(2) 显式空闲链表:在隐式空闲链表块结构的基础上,在每个空闲块中添加一个pred(前驱)指针和一个succ(后继)指针。
图 7.11 隐式空闲链表结构与显式空闲链表结构
(3) 分离的空闲链表:将块按块大小划分大小类,分配器维护一个空闲链表数组,每个大小类一个空闲链表,减少分配时间同时也提高了内存利用率。C语言中的malloc程序包采用的就是这种方法。
7.10本章小结
本章介绍了hello的存储管理机制。讨论了虚拟地址、线性地址、物理地址,介绍了段式管理与页式管理、VA 到 PA 的变换、物理内存访问,以及 hello 进程运行 fork 、execve函数时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等。现代计算机系统为提高内存存储效率和使用率以至程序运行的效率使用了大量的机制和技术,这些技术保证计算机系统以高效的方式运行。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化——文件
文件的类型:
1、 普通文件(regular file):包含任意数据的文件。
2、 目录(directory)(文件夹):包含一组链接的文件,每个链接都将一个文件名映射到一个文件。
3、 套接字(socket):用来与另一个进程进行跨网络通信的文件
4、 命名通道
5、 符号链接
6、 字符和块设备
设备管理——unix io接口
将设备模型化为文件的方式允许Linux内核引入一个简单、低级的应用接口,称为Unix IO,这一设定使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O 接口的几种操作:
1. 打开文件:程序要求内核打开文件,内核返回一个小的非负整数,用于标识这个文件。程序在只要记录这个描述符便能记录打开文件的所有信息。
2. shell 在进程的开始为其打开三个文件:标准输入、标准输出和标准错误。
3. 改变当前文件的位置:对于每个打开的文件,内核保存着一个文件位置 k,初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用 程序能够通过执行 seek 操作显式地设置文件的当前位置为 k。
4. 读写文件:一个读操作就是从文件复制 n>0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k+n。给定一个大小为 m 字节的文件, 当 k>=m 时执行读操作会出发一个称为 EOF 的条件,应用程序能检测 到这个条件,在文件结尾处并没有明确的 EOF 符号。
5. 关闭文件:内核释放打开文件时创建的数据结构以及占用的内存资源,并将描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 Unix I/O 函数:
1. open()函数
函数原型:int open(char *filename, int flags, mode_t mode);
函数功能:open 函数将 filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符,flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
2. close()函数
函数原型:int close(int fd);
函数功能:关闭一个打开的文件,关闭未打开的文件会出错。
3. read()函数
函数原型:ssize_t read(int fd, void *buf, size_t n);
函数功能:read 函数从描述符为 fd 的当前文件位置赋值最多 n 个字节到内存位置 buf。返回值-1 表示一个错误,0 表示 EOF,否则返回值表示的是实际传送的字节数量。
4. write()函数
函数原型:ssize_t write(int fd, const void *buf,size_t);
函数功能:write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件位置。
8.3 printf的实现分析
1. printf函数源代码:
- int printf(const char *fmt, ...)
2. {
- int i;
- char buf[256];
- 5.
- va_list arg = (va_list)((char*)(&fmt) + 4);
- i = vsprintf(buf, fmt, arg);
- 8. write(buf, i);
- 9.
- 10. return i;
- }
分析:
(1)printf函数调用了vsprintf函数,后者通过系统调用函数write完成输出
(2)char *fmt表示待输出的格式串
2.vsprintf函数源代码:
- int vsprintf(char *buf, const char *fmt, va_list args)
2. {
- char* p;
- char tmp[256];
- 5. va_list p_next_arg = args;
- 6.
- for (p=buf;*fmt;fmt++) {
- if (*fmt != '%') {
- *p++ = *fmt;
- 10. continue;
- }
- fmt++;
- 15. switch (*fmt) {
- 16. case 'x':
- 17. itoa(tmp, *((int*)p_next_arg));
- 18. strcpy(p, tmp);
- 19. p_next_arg += 4;
- 20. p += strlen(tmp);
- 21. break;
- 22. case 's':
- 23. break;
- 24. default:
- 25. break;
- }
- }
- 29. return (p - buf);
- }
分析:vsprintf 的作用就是将 printf 的参数按照各种各种格式进行分析,将要输出的字符串存在 buf 中,最终返回要输出的字符串的长度。
3.write函数汇编指令
- write:
- mov eax, _NR_write
- mov ebx, [esp + 4]
- mov ecx, [esp + 8]
- int INT_VECTOR_SYS_CALL
分析:通过 write 函数的汇编实现可以发现,它首先给寄存器传递了几个参数,然后执行INT_VECTOR_SYS_CALL,代表通过系统调用 syscall,syscall 将寄存器中的字节通过总线复制到显卡的显存中,直至遇到字符’0’。显示芯片按照刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点(RGB 分量)。由此 write 函数显示一个已格式化的字符串。
字符显示驱动子程序实现从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)的功能。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。这样就得到了看到的显示在屏幕是的格式字符串了。
8.4 getchar的实现分析
8.4.1异步异常-键盘中断的处理:
当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中
8.4.2getchar函数的分析:
getchar函数的源代码如下:
- int getchar(void)
- {
- static char buf[BUFSIZ];
- static char* bb=buf;
- static int n=0;
- if(n==0)
- {
- n=read(0,buf,BUFSIZ);
- bb=buf;
- }
- return(--n>=0)?(unsigned char)*bb++:EOF;
- }
可以看到,getchar 调用了 read 函数,read函数也通过 sys_call 调用内核中的系统函数,将读取存储在键盘缓冲区中的 ASCII 码,直到读到回车符,然后返回整个字符串,getchar 函数只从中读取第一个字符,其他的字符被缓存在输入缓冲区。
8.5本章小结
本章分析了Linux的I/O设备管理方法,Unix I/O接口及其函数,以及printf函数的详细实现和getchar函数的实现。通过对getchar函数的分析同时也了解了作为异步异常之一的键盘中断的处理。
结论
1.hello一生的概述
hello程序虽然作为一个只有数十行的C语言程序,但它从一个源程序变成了可执行目标文件,并不是在VS中简单地按下运行按钮就可以运行了。它的编译、运行、终止和回收离不开计算机系统各方面的协同工作才得以实现,hello复杂的一生具体概括如下:
1) hello.c源代码首先被C预处理器(cpp)处理,拓展带#的内容,变成hello.i文本文件。
2) hello.i接着被编译器(ccl)转化成了汇编文本hello.s;
3) 汇编文本hello.s还是无法被机器直接处理,机器只能识别01序列,所以还要进一步转化,由汇编器(as)将hello.s生成可重定位目标文件hello.o;
4) hello 里面调用的printf等库函数是不含在hello.o里面的,无法运行,于是链接器通过静态或动态链接终于生成了可执行目标文件hello.out。
5) 用户在shell-bash中键入执行hello程序的命令(./hello)后,shell-bash解释用户的命令,找到hello可执行目标文件并为其执行fork创建新进程。
6) 得到的新进程通过execve完成在当前进程上下文中对hello程序的加载,这时hello才开始执行。
7) hello执行过程中,有时接收内核调度时会被暂时保存上下文,切换到其他进程执行;有时也会发生缺页异常等故障、系统调用等陷阱以及接收到各种信号,这些都需要操作系统(cpu等)与硬件设备(CACHE、DRAM)的协同工作进行处理。
8) hello执行的过程中会访问其虚拟空间内的指令和数据,需要借助各种硬件(MMU、TLB)、软件机制(页表机制、动态内存分配)来快速、高效完成。
9) 当hello要调用printf、getchar等函数来实现输入与输出,这些函数的实现依靠Linux系统IO设备管理、Unix IO接口等。IO设备让hello在屏幕上证明它曾经来过!
10) hello程序运行结束后,父进程shell-bash会进行回收,内核也会清除在内存中为其创建的各种数据结构和信息。hello短暂的一生宣告结束!
2.个人感悟
计算机系统是一个非常精细的系统,譬如流水线的结构精确到每个几微秒周期;它亦是非常巧妙的,比如引入了多级的缓存结构。想到这我不由赞叹计算机系统初代设计者的高超智慧。这学期虽然由于疫情原因大多数课时都在线上进行,但并不妨碍我在这门课我学到了很多东西,这门课程引导我开始从真实的程序员视角来看待问题:不仅仅关注代码是否可以运行,更要关注代码的质量、效率,把自己的代码当成艺术品一样打磨。
最后十分感谢一学期以来老师的辛勤付出以及耐心答疑。路漫漫其修远兮,就像hello一样,我们的人生才刚刚开始,一步步走好后面的路,不要畏惧山重水复(异常),耐心求索会有柳暗花明(异常修复机制)的!
附件
文件名 |
文件说明 |
hello.c |
源代码 |
hello.i |
预处理后的文件 |
hello.s |
编译之后的汇编文件 |
hello.o |
汇编之后的可重定位目标文件 |
hello |
链接之后的可执行目标文件 |
hello1.elf |
hello.o 的 ELF格式 |
hello1_dis |
hello.o 的反汇编代码 |
hello.elf |
hello的ELF 格式 |
hello_dis |
hello 的反汇编代码 |
参考文献
[1] 大卫R.奥哈拉伦,兰德尔E.布莱恩特. 深入理解计算机系统[M]. 机械工业出版社,2016.
[2] jiangxt211. C预处理Available at https://blog.csdn.net/jiangxt211/article/details/87522370
[3] printf函数的实现,csdn博客
https://blog.csdn.net/fengxinlinux/article/details/52064816
[4] getchar函数实现,csdn博客
https://blog.csdn.net/Huang_WeiHong/article/details/109455150
[5] ELF文件格式详解,csdn博客
[6] Linux内存寻址之分段机制及分页机制 云平台
https://cloud.tencent.com/developer/article/1172683