第1章 概述
1.1 Hello简介
Hello的P2P,020的整个过程:hello程序的生命是从一个高级c语言程序开始的,因为这种形式能被人读懂.但是,计算机系统是读不懂高级语言的.
为了在系统上运行
hello.c 程序,每条 C 语句都必须要被其他程序转化为一系列的低级机器语言指令。,于是要对hello.c进行cpp的预处理、ccl的编译、
as的汇编、
ld的链接最终成为可执行目标程序hello以下简单介绍一下这四个阶段
-
预处理阶段:
预处理器 cpp 根据以字符 # 开头的命令,修改原始的C 程序,比如 Hello.c 中第一行#include<studio.h> 命令告诉预处理器读取系统文件 stdio.h 的内容,并把它直接插入到程序中。结果就得到另一个 C 程序,通常是以 .i 作为文件扩展名。 -
编译阶段:
编译器ccl 将文本文件hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序,汇编语言程序中的每条语句都以一种标准的文本格式确切的描述一条低级机器语言指令。汇编语言能为不同高级语言的不同编译器提供通用的输出语言。 -
汇编阶段:
汇编器as 将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件
hello.o 中,hello.o 文件是一个二进制文件,它的字节编码是机器预言指令而不是字符。如果我们用文本编辑器打开
hello.o 文件,将会是一堆乱码。 -
链接阶段:
在hello.c 程序中,我们看到程序调用了 printf 函数,它是每个 C 编译器都会提供的标准C 库中的一个函数。printf函数存在于一个名为printf.o 的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的 hello.o 程序中。链接器ld 就是负责处理这种合并,结果就得到一个 hello 文件,它是一个可执行目标程序,可以被加载到内存中,由系统运行。
在linux下输入./hello并敲入回车执行时,shell是一个命令行解释器;输入每个字符都被逐一读取到CPU的寄存器中,然后被保存到存储器中,当我们敲回车时,证明命令结束,。然后外壳执行一系列指令来加载可执行的 hello 文件,将 hello 目标文件中的代码和数据从磁盘复制到主存。数据包括最终会被输出结果, 一旦目标文件中的代码和数据被加载到主存,处理器就开始执行 hello 程序的 main 程序中的机器语言指令。这些指令将printf中的内容字符串的字节从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。在整个过程中
1.2 环境与工具
-
硬件环境:Intel Core i7-7700HQ,8G RAM,256G SSD +1T HDD.
-
软件环境:Ubuntu18.04
-
开发与调试工具:vim,gcc,as,ld,edb,readelf,HexEdit等
1.3 中间结果
文件名称 | 文件作用 |
---|---|
hello.c | hello原程序代码 |
hello.s | 编译之后的汇编结果 |
hello.s | 编译之后的汇编结果 |
hello.o | 汇编后的可重定位执行文件 |
hello | 链接之后的可执行最终程序 |
helloas.txt | hello.o的反汇编代码 |
helloELF.txt | hello.o的elf格式文件 |
hello.txt | hello的反汇编代码 |
helloelf.txt | hello的elf格式文件 |
1.4 本章小结
本章主要为整个文章的概述,简要的介绍了hello程序的处理执行过程,以及cpp的预处理、ccl的编译、
as的汇编、
ld的链接的一些基本概念,以及对实验环境的介绍,中间处理结果的文件功能的介绍,为整篇文章的准备与介绍阶段。
第2章 预处理
2.1 预处理的概念与作用
预处理 功能主要包括:
宏定义,文件包含,条件编译三部分。
分别对应宏定义命令,文件包含命令,条件编译命令 三部分实现。
预处理过程读入源代码,检查包含预处理指令的语句和宏定义,并对源代码进行响应的转换。预处理过程还会删除程序中的注释和多余的空白字符。
预处理指令是以#号开头的代码行。
#号必须是该行除了任何空白字符外的第一个字符。
#后是指令关键字,在关键字和#号之间允许存在任意个数的空白字符。整行语句构成了一条预处理指令,该指令将在编译器进行编译之前对源代码做某些转换。
功能:在集成开发环境中,编译,链接是同时完成的。其实,C语言编译器在对源代码编译之前,还需要进一步的处理:预编译。预编译的主要作用如下:
1、将源文件中以”include”格式包含的文件复制到编译的源文件中。
2、用实际值替换用“#define”定义的字符串。
3、根据“#if”后面的条件决定需要编译的代码。
2.2在Ubuntu下预处理的命令
命令:cpp hello.c > hello.i
2.3 Hello的预处理结果解析
首先通过编辑器打开预处理后的代码
发现代码由原来的短短几十行变成了恐怖的3118行,并且可以观察到原hello代码在最后,而在文件开头多出了许多文件路径等内容
在预处理阶段,由于源代码头文件包括Stdio.h,unistd.h,stdlib.h,#include指令告诉预处理器打开一个特定的文件,将它的内容作为正在编译的文件的一部分“包含”进来,以stdio.h为例,当读#include<stdio.h>时,cpp打开/usr/include/stdio.h 而其也使用了 #define 语句,cpp 对其不断展开,于是最终.i 程序中是没含有#define但其中却使用了大量的#ifdef #ifndef 的语句,cpp 会对条件进行判断以决定是否需要执行其中的逻辑。其他处理过程基本相同。
2.4 本章小结
本章主要通过预处理命令对hello进行了预处理,了解了预处理的概念与作用,并根据预处理结果对预处理过程进行了分析
第3章 编译
3.1 编译的概念与作用
编译阶段所有做的工作就是通过词法分析和语法分析,在确认所有指令都符合语法规则之后,将其翻译成等价的中间代码或者是汇编代码。
编译阶段会对代码进行优化处理,不仅涉及到编译技术本身,还涉及到机器的硬件环境。优化分为两部分:
l 不依赖于具体计算机的优化。主要是删除公共表达式、循环优化(代码外提、强度削弱、变换循环控制、已知量的合并等)、无用赋值的删除等
l 同机器硬件结构相关的优化。主要考虑如何充分利用机器的硬件寄存器存放的有关变量的值以减少内存的访问次数;根据机器硬件执行指令的特点对指令进行调整使目标代码比较短,执行效率更高等。
编译及作用:编译器将hello.i 编译成hello.s,它包含一个汇编语言程序。
译器的构建流程主要分为 3 个步骤:
-
词法分析器,用于将字符串转化成内部的表示结构。
-
语法分析器,将词法分析得到的标记流(token)生成一棵语法树
-
目标代码的生成,将语法树转化成目标代码。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
3.3.1汇编指令
指令 | 含义 |
---|---|
.file | 声明源文件 |
.text | 声明代码段 |
.text | 声明代码段 |
.section .rodata | 声明rodata节 |
.globl | 声明全局变量 |
.type | 用来指定一个符号的函数类型或对象类型 |
.size | 声明大小 |
.long .string | 声明一个long或string类型 |
.align | 声明对指令或者数据的存放地址进行对齐的方式 |
3.3.2 数据
观察hello.s,其中用到的数据类型包括:整数,字符串,数组。
一、整数
程序中涉及的整数类型数据有
1>
sleepsecs为全局变量整数类型且已被赋值,.data节为存放已经初始化的全局和静态变量的数据区域,故编译后存放在.data节中,
从图中可以看出,sleepsecs在.text中声明为全局变量,在.data中设置数据属性,对齐方式为4、设置类型为对象、设置大小为4字节、设置为long类型其值为2。
2> 主函数的传入第一个参数
3>局部变量,存储在寄存器或栈中,两者选择具体决定于寄存器的数目以及优化程度,在hello.s编译器将i占据了栈中的 4B 大小空间。
4>立即数:在汇编代码中直接出现的整数数据类型
二、字符串
1>
“Usage: Hello 1170300527 吴昊!\n”,输出字符串,只读字符串,在.rodata中声明
汉字以UTF-8存储
2>“Hello %s %s\n”,argv[1],argv[2],输出字符串,只读字符串在.rodata中声明
三、数组
char *argv[] main函数执行时输入的命令行,argv 作为存放 char指针的数组同时是第二个参数传入。
Argv中元素大小为8B,指针指向一片分配好的连续空间,在main函数中用到了argv[1]和argv[2],按照元素的大小计算出数据地址从而取出数据如图3.3.2-4,addq $8,%rax 可以看出取了下一个地址,位移了8也可以看出元素大小为8
3.3.3 赋值
1>
全局变量sleepsecs声明为int sleepsecs=2.5,因为是int类型全局变量,所以sleepesecs为在.data节中声明值为2的long类型(在linux下int与long大小相同)
2>
int i=0;整数类型的数据赋值使用mov指令进行,并根据数据类型选择不同的mov指令,因为i为int型,所以选择movl 图3.3.3-1 i的movl赋值
3.3.4 类型转换
1>
int sleepsec=2.5,2.5为double类型(浮点数默认为double),而声明类型却是int,故用到了隐式的类型转换将2.5转化为int型的2
2>
浮点数转int时会直接舍去尾数部分,但可能遇到溢出的状况于是可能产生一个随机不确定的数
3.3.5 算术运算
1>
i++,使int型i加1,通过运算符addl来完成,add表示+,因为使int型,长度是4故使用addl
2>
leaq .LC1(%rip),%rdi,汇编指令,使用了加载有效地址指令 leaq
计算 LC1 的段地址%rip+.LC1 并传递给%rdi
3>
以下简要介绍一下汇编中的算数操作:
指令 | 作用 |
---|---|
ADD dst,src; | dst<–dst+src |
SUB dst,src; | dst<–dst-src |
ADC dst,src; | dst<–dst+src+CF |
SBB dst,src; | dst<–dst-src-CF |
INC dst ; | dst<–dst+1 |
DEC dst; | dst<-dst-1 |
NEG dst; | dst<–0-dst |
MULQ S | R[%rdx]:R[%rax]=S*R[%rax](无符号) |
IMULQ S | R[%rdx]:R[%rax]=S*R[%rax](有符号) |
IDIVQ S | R[%rdx]=R[%rdx]:R[%rax] mod S(有符号) |
DIVQ S | R[%rdx]=R[%rdx]:R[%rax] mod S(无符号) |
R[%rax]=R[%rdx]:R[%rax] div S |
3.3.6 关系操作
1>
argc!=3,当argv不等于3时满足条件.hello.s可以看到汇编代码为cmpl $3,-20(%rbp),cmpl为比较指令,%rbp-20为argc,本质上是计算argc-3通过判断结果来设置标志位
2>
i<10:当i小于10时满足条件,hello.s中代码为cmpl
$9,-4(%rbp),判断方法与上一条基本相同
3>
常用的汇编关系操作指令
指令 | 效果 | 原理 |
---|---|---|
CMP S1,S2 | S2-S1 | 比较-设置条件码 |
TEST S1,S2 | S1&S2 | 测试-设置条件码 |
SET? D | D=? | 按照?将条件码设置 D |
3.3.7 跳转指令
1>
if(argv!=3):当条件满足及argv不等于3时执行跳转。汇编通过使用cmpl指令进行比较,并通过返回的条件码判断是否执行跳转到L2继续执行。
2>
for(i=0;i<10;i++):循环指令,通过i计数循环0-9共10次,搜先直接跳转进循环,之后通过cmpl指令比较i与9的大小,当i<10时继续跳转进循环.
3.3.8 函数
C语言有自己的函数库,也可以自己定义函数将代码进行封装,方便以后的调用,将该函数定义的参数传入进行处理与是否得到返回值.如X中调用y函数需要进行以下操作
1>
传递控制: 当程序在X中运行到y的跳转指令时,程序计数器设置为y的代码起始地址,返回时,要将程序计数器设置为原X中调用y函数的下一条指令地址
2>
传递参数: X必须向y提供一个或多个参数,y也将返回一个参数,通常返回值在%eax中
3>
分配与释放内存: 在开始时,y可能需要为局部变量分配空间,而在返回前,又必须释放这些空间
本程序中涉及到的函数:
1> main函数:
a>
main函数被call调用执行(被系统启动函数 __libc_start_main 调用),call 指令将下一条指令的地址
dest 压栈, 然后跳转到 main 函数。
b>
传递参数,调用main函数时传送了两个参数, int
argc,char *argv[],分别存储在%rdi与%rsi中,返回值为return 0,
c>
分配和释放内存: %rbp记录栈底,函数分配的空间都在栈中,当函数调用结束后,通过leave指令恢复栈的空间为调用前的状态并ret结束
2> Printf函数;
a>
传递参数:第一次 printf 将%rdi 设置为“Usage: Hello 1170300527 吴昊! \n”字符串的首地址。第二次 printf 设置%rdi 为“Hello %s %s\n” 的首地址,设置%rsi 为 argv[1],%rdx 为 argv[2]。
b>
控制传递:第一次 printf 因为只有一个字符串参数,所以 call puts@PLT;第二次 printf 使用 call printf@PLT。
c>
分配和释放内存,同上
3> exit函数
a>
传递参数:将%edi 设置为 1。
b>
控制传递:call exit@PLT。
c>
分配和释放内存,同上
4> sleep 函数:
a>
传递参数:将%edi 设置为 sleepsecs。
b>
控制传递:call sleep@PLT。
c>
分配和释放内存,同上
将sleepsecs的值送到了%edi中并传入函数
5> getchar函数:
a>
传递参数:无传递参数
b>
控制传递:call gethcar@PLT
c>
分配和释放内存,同上
3.4 本章小结
本章通过编译器对上一章生成的.i文件编译成.s文件,并对数据类型,赋值,类型转换,算数操作,关系操作,位移控制,函数进行了分析,将编译过程进行了全面的分析,并对一些常用的汇编指令进行了阐述
编译器由.i文件处理成了.s文件的汇编代码,现在我们对汇编代码也有了初步的了解可以基本看do懂汇编语言并进行分析
第4章 汇编
4.1 汇编的概念与作用
汇编过程将上一步的汇编代码转换成机器码,这一步产生的文件叫做目标文件,是二进制格式。gcc汇编过程通过as命令完成。
工作过程:输入汇编语言源程序。检查语法的正确性,如果正确,则将源程序翻译成等价的二进制或浮动二进制的机器语言程序,并根据用户的需要输出源程序和目标程序的对照清单;如果语法有错,则输出错误信息,指明错误的部位、类型和编号。最后,对已汇编出的目标程序进行善后处理。
汇编器(as)将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在包含程序指令码的二进制.o目标文件中。
4.2 在Ubuntu下汇编的命令
命令:gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先了解以下ELF文件的格式
使用readelf -a hello.o > helloELF.txt获得hello.o的ELF格式文件
1>
ELFHearer:用来描述整个文件的组织。节区部 分包含链接视图的大量信息:指令、数据、符号表、重定位信息等等。以Magic(ELF文件的魔数)其余的部分为帮助链接器语法分析和解 释目标文件的信息,其中包括 ELF
头的大小、目标文件的类型、机器类型、 字节头部表(section header table)的文件偏移,以及节头部表中条目的大
小和数量等信息。
2>
Section header:包含了描述文件节区的信息,每个节区在表中都有一项,每一项给出诸如节区名称、节区大小这类信息。用于链接的目标文件必须包含节区头部表,其他目标文件可以有,也可以没有这个表。
3>
Program header:告诉系统如何创建进程映像。用来构造进程映像的目标文件必须具有程序头部表,可重定位文件不需要这个表。
4>
重定位节.rela.text :在.text 节中位置的列表,包含重定位的信息,链接时链接器把这个目标文件和其他文件组合时,进行重定位并根据信息修改位置。如图 4.3-5中 8 条重定位信息分别是对.L0(第一个 printf 中的字符 串)、puts 函数、exit 函数、.L1(第二个
printf 中的字符串)、printf 函数、
sleepsecs、sleep 函数、getchar 函数进行重定位声明。
.rela 节信息
offset | 需要进行重定向的代码在.text 或.data 节中的偏移位置,8 个 字节. |
---|---|
Info | 包括 symbol 和 type 两部分, 其中 symbol 占前 4 个字节, type 占后 4 个字节,symbol 代 表重定位到的目标在.symtab 中的偏移量,type 代表重定位 的类型 |
Addend | 计算重定位位置的辅助信息, 共占 8 个字节 |
Type | 重定位到的目标的类型 |
Name | 重定向到的目标的名称 |
以.L1为例简要叙述重定位过程:链接器根据info的信息在.symtab中查询信息,得到info.symbol=0x05,发现重定位的目标链接到了第四个Section .rodata,设重定位条目为 a,根据图 4.3-6 知 r 的构造为:
a.offset=0x18, a.symbol=.aodata, a.type=R_X86_64_PC32, a.addend=-4,
计算重定位的目标地址方法
refptr = s +a.offset (1)
refaddr= ADDR(s) + a.offset (2)
*refptr= (unsigned) (ADDR(r.symbol) + r.addend-refaddr)(3)
其中(1)指向 src 的指针(2)计算 src 的运行时地址,(3)中, ADDR(a.symbol)计算 dst 的运行时地址,在本例中,ADDR(a.symbol)获得 的是 dst 的运行时地址,因为需要设置的是绝对地址,即 dst 与下一条指令
之间的地址之差,所以需要加上 a.addend=-4。 之后将 src 处设置为运行时值*refptr,完成该处重定位。
5>
.rela.eh_frame : eh_frame 节的重定位信息
6>
.symtab:符号表,用来存放程序中定义和引用的函数和全局变量的信息。
重定位需要引用的符号都在其中声明。
4.4 Hello.o的结果解析
使用objdump -d hello.o > helloas.txt获得反汇编代码
1> 分支转移:反汇编代码中的跳转指令不再使用段名称如L0、L1等,而是采用跳转到指定地址的方式进行调用采用确定的段名称进行跳转
2> 函数调用:在上一章生成的.s文件中函数调用后时函数的名称,而通过hello.o生成的反汇编代码中确实下一条指令的地址,这是因为hello中调用的都是共享库中的函数如printf、getchar,这些都需要在下一张=章链接中通过链接器的链接才能完成得到运行时的执行地址,现在的地址时不确定的,对于这些不确定的地址call指令都会先设置偏移地址为0并在.rela,text中添加其重定位条目,最终在下一章中链接过程中完成。
3> 全局变量的访问:在.s 文件中,访问 rodata(printf 中的字符串),使用段名称+%rip,在反汇编代码中 0+%rip,因为 rodata 中数据地址也是在运行时确定的,故访问也是需要进行重定位的。所以在汇编成为机器语言的时候,将操作数设置为全0并为其添加重定位条目。
4.5 本章小结
本章将hello.s进行了汇编处理生成了可重定位的目标.o文件为下一步链接做准备,了解了汇编的作用及功能以及汇编指令。
生成了重定位的elf格式文件helloELF,通过查看该文件了解了其各节的基本信息,特别是重定位项目分析;并对hello.o进行了反汇编生成hellooas.txt并与上一章生成的.s文件进行比较了解了汇编语言到机器语言的转换关系
第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.3 可执行目标文件hello的格式
Elf文件的格式在上一节中已经做过了介绍,这里就不再赘述
同样使用readelf -a hello > helloelf.txt得到ELF格式的文件
1>
Section Headers是对hello节信息的声明用来描述整个文件的组织。节区部
分包含链接视图的大量信息:指令、数据、符号表、重定位信息等等。以Magic(ELF文件的魔数)其余的部分为帮助链接器语法分析和解 释目标文件的信息,其中包括 ELF
头的大小、目标文件的类型、机器类型、 字节头部表(section header table)的文件偏移,以及节头部表中条目的大
小和数量等信息。
2> Section header:包含了描述文件节区的信息,每个节区在表中都有一项,每一项给出诸如节区名称、节区大小这类信息。用于链接的目标文件必须包含节区头部表,其他目标文件可以有,也可以没有这个表。
3>
Program header:告诉系统如何创建进程映像。用来构造进程映像的目标文件必须具有程序头部表,可重定位文件不需要这个表。
4>
.rela :节的重定位信息
5>
.symtab:符号表,用来存放程序中定义和引用的函数和全局变量的信息。 重定位需要引用的符号都在其中声明。
5.4 hello的虚拟地址空间
使用edb加载hello,Data Dump窗口显示的为加载到虚拟内存的hello程序
可以看出程序被载入的虚拟地址为0x400000并且与ELF文件的Magic相同
查看elf文件的Program Headers如图5.3-3,程序头表在执行时告诉链接器器运行时加载的内容并提供动态链接的信息,每一表项。每一个表项提供 了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方 面的信息,下面简要介绍程序包含的几个段信息
-
PHDR 指定程序头表在文件及程序内存映像中的位置和大小。此段类型不能在一个文件中多次出现。此外,仅当程序头表是程序内存映像的一部分时,才可以出现此段。此类型(如果存在)必须位于任何可装入段的各项的前面。
-
INTERP 指定要作为解释程序调用的以空字符结尾的路径名的位置和大小。对于动态可执行文件,必须设置此类型。此类型可出现在共享目标文件中。此类型不能在一个文件中多次出现。此类型(如果存在)必须位于任何可装入段的各项的前面。有关详细信息,请参见程序的解释程序。
-
LOAD 指定可装入段,通过 filesz 和 memsz 进行描述。文件中的字节会映射到内存段的起始位置。如果段的内存大小(memsz)大于文件大小(filesz),则将多余字节的值定义为0这些字节跟在段的已初始化区域后面。文件大小不能大于内存大小。程序头表中的可装入段的各项按升序显示,并基于vaddr 成员进行排列。
-
DYNAMIC 指定动态链接信息。
-
NOTE 指定辅助信息的位置和大小。
-
GNU_STACK:权限标志,标志栈是否是可执行的。
-
GNU_RELRO:指定在重定位结束之后那些内存区域是需要设置只读
5.5 链接的重定位过程分析
如图5.5-1所示,观察hello与hello.o的main函数地址,hello.o的main函数首地址为0而经过连接后的hello的main函数首地址为0x400532,这是main函数在运行时相对.text段的偏移位置
在链接过程中动态链接器为函数定义了程序入口_start、初 始化函数_init,_start 程序调用main函数并链接进了printf,getchar等库函数,链接器将这些函数链接到一起使程序能够最终执行
在可重定位的hello.o的返汇编代码中字符串常量或者变量(已初始化)仅仅是使用0x0(%rip)来暂时表示其位置(占位),跳转地址的偏移量也都为0,这些并不是最终的地址,链接时,链接器会根据可重定位目标文件的重定位节给每个符号分配了最终的地址。
.rodata 引用:链接器在进行连接的时候,解析重定条目时发现两个类型为 R_X86_64_PC32 的对.rodata 的重定位(printf 中的两个字符串),.rodata 与.text 节之间的相对距离确定,因此链接器直接修改 call 之后的值为目标地址与下一条 指令的地址之差,指向相应的字符串。
5.6 hello的执行流程
在执行hello程序时,由程序入口_start()函数开始执行, _start()函数会进行准备工作调用__libcstart_main()函数首先调用_libccsu_init()函数调用_init函数对main进行初始化运行环境,之后
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。_libcstart_main()函数调用main()函数。在main()函数结束后,将返回_libcstart_main()函数,并调用_libc_csu_fini(),该函数调用_fini()函数,做一些资源的回收工作。
下面为执行过程中的主要函数及地址
名称 | 地址 |
---|---|
hello!_start | 0x400500 |
hello!_init | 0x400488 |
-libc-2.27.so!__libc_csu_init | 0x4005c0 |
hello!main | 0x400532 |
hello!puts@plt | 0x4004b0 |
hello!exit@plt | 0x4004e0 |
5.7 Hello的动态链接分析
使用动态链接生成可执行文件,在启动时首先执行动态链接器,。动态链接器使用过程链接表 PLT+全局偏移量 表 GOT 实现函数的动态链接,GOT 中存放函数的目标地址,PLT 中存放跳转到 GOT 中目标地址的代码逻辑。
调用dl_init之前,每一条PIC函数调用的目标地址实际指向都是PLT 中的代码逻辑,GOT 存放的是 PLT 中函数调用指令的下一条指令地址。如图5.7-1
调用dl_init之后,观察截图与调用前对比发现标记出的部分发生了改变,如图5.7-2,新的地址为0x7f66dacec170,0x7f66daada750,再次通过Data dump查看,如图5.7-3该段为重定位表,图5.7-4为动态链接器 ld-linux.so 运行时地址。
动态链接器将可执行文件和链接器本身的符号表合并到全局符号表,之后链接器寻找执行文件所依赖的共享对象,通过. Dynamic中的入口DT_NEEDED, 链接器可以列出可执行文件所需要的所有共享对象,并放入到集合中,链接器由集合中的名字找到相应的文件读取相应的ELF文件头和“.dynamic”段, 然后将它相应的代码段和数据段映射到进程空间中,如果ELF共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放到装载集合中,并不断循环直到全部加载
5.8 本章小结
本章对.o文件进行了最后的链接操作,使之成为了一个可执行程序并可最后执行输出结果。
通过本章,更深刻的了解到了链接的整个过程和原理,并对hello的ELF文件格式进行了分析比较。通过edb查看hello程序,了解了虚拟地址和执行流程。对重定位过程与动态链接进行了分析,到此为止hello终于可以真正的诞生运行起来了,附上hello运行截图
第6章 hello进程管理
6.1 进程的概念与作用
进程指程序的一次运行过程。更确切说,进程是具有独立功能的一个程序关于某个数据集合的一次运行活动,因而进程具有动态含义。同一个程序处理不同的数据就是不同的进程。
进程是os对cpu执行的程序的运行过程的一种抽象,进程有自己的生命周期,它由于任务的启动而创建,随着任务的完成(或终止)而消亡,它所占用的资源也随着进程的终止而释放。
一个可执行目标文件(即程序)可被加载执行多次,也即,一个程序可对应多个不同进程。
操作系统(管理任务)以外的都属于用户的任务。
计算机处理的所有用户的任务由进程来完成。
为强调进程完成的是用户的任务,通常将进程称为用户进程
计算机系统中的任务通常就是指进程。
6.2 简述壳Shell-bash的作用与处理流程
§ shell 是一个交互型应用级程序,代表用户运行其他程序
§ 功能:
(1)命令解释程序。用户在提示符之后输入命令,由shell予以解释执行;
(2)高级程序设计语言。利用shell程序设计语言可以编写功能强且代码简单的程序,可把Linux命令有机结合,大大提高编程效率,充分利用Linux系统的开放性能,设计出适合用户需要的命令。
§ 处理流程:
1、读取用户由键盘输入的命令行。
2、分析命令,以命令名作为文件名,并将其它参数改造为系统调用execve( )内部处理所要求的形式。
3、终端进程调用fork( )建立一个子进程。
4、终端进程本身调用wait4()来等待子进程完成(如果是后台命令,则不等待)。当子进程运行时调用execve(),子进程根据文件名到目录中查找有关文件,调入内存,执行这个程序。
5、如果命令末尾有&,则终端进程不用执行系统调用wait4(),立即发提示符,让用户输入下一条命令;否则终端进程会一直等待,当子进程完成工作后,向父进程报告,此时中断进程醒来,作必要的判别工作后,终端发出命令提示符,重复上述处理过程。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程,
int fork(void) ,子进程中,fork返回0;父进程中,返回子进程的PID,新创建的子进程几乎但不完全与父进程相同: 子进程得到与父进程虚拟地址空间相同的(但是独立的) 一份副本,子进程获得与父进程任何打开文件描述符相同的副本,最大区别:子进程有不同于父进程的PID fork函数:被调用一次,返回两次
在终端中输入./hello 1170300527 吴昊并敲入回车告诉命令行已经输入完成,shell会对命令行进行解析因为其不是shell中的内置命令,会判断是否为当前目录下的可执行文件,之后会fork一个子进程,子进程与父进程几乎完全相同,并拥有不同的pid
6.4 Hello的execve过程
之所以叫新程序的执行,原因是这部分内容一般发生在fork()和vfork()之后,在子进程中通过系统调用execve()可以将新程序加载到子进程的内存空间。这个操作会丢弃原来的子进程execve()之后的部分,而子进程的栈、数据会被新进程的相应部分所替换。即除了进程ID之外,这个进程已经与原来的进程没有关系了。
在shell执行hello时,它也是首先调用execve()这个系统调用,实际上hello进程的父进程就是shell进程,他是shell进程fork出来的一个子进程,然后执行execve函数之后再执行hello,下面为execve函数原型
l
filename:包含准备载入当前进程空间的新程序的路径名。既可以是绝对路径,又可以是相对路径。
l
argv[]:指定了传给新进程的命令行参数,该数组对应于c语言main函数的argv参数数组,格式也相同,argv[0]对应命令名,通常情况下该值与filename中的basename(就是绝对路径的最后一个)相同。
l
envp[]:最后一个参数envp指定了新程序的环境列表。参数envp对应于新程序的environ数组。
调用execve(),进程能够以hello序来替换当前运行的程序。再此过程中,将丢弃旧有程序,进程的栈.数据以及堆段会被hello程序所替换。这个 execve函数就提供了一个在进程中启动另一个程序执行的方法。
它根据指定的文件名或目录名找到hello的可执行文件,并用它来代替当前进程的执行映像。也就是说,execve调用并没有生成新进程,一个进程一旦调用 execve函数,它本身就“死亡”了,系统把代码段替换成新程序的代码,放弃原有的数据段和堆栈段,并为新程序分配新的数据段与堆栈段,惟一保留的就是进程的 ID。也就是说,对系统而言,还是同一个进程,不过执行的已经是另外一个程序了。
6.5 Hello的进程执行
1> 逻辑控制流:每个进程似乎独占地使用CPU,通过OS内核的
上下文切换 机制提供,每个进程拥有一个独立的逻辑控制流,使得程序员以为自己的程序在执行过程中独占处理器
2> 私有地址空间:每个进程似乎独占地使用内存系统,OS内核的虚拟内存 机制提供,每个进程拥有一个私有的虚拟地址空间,使得程序员以为自己的程序在执行过程中独占使用存储器
3> 时间片:每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间
4> 上下文切换:进程由常驻内存的操作系统代码块(称为内核)管理,内核不是一个单独的进程,而是作为现有进程的一部分运行;控制流通过上下文切换
从一个进程传递到另一个进程。
5> 用户模式与内核模式:进程的运行一般有两个模式,用户模式和内核模式,内核模式的权限更高,当处于内核态时,可以执行任何指令并访问系统空间。处于用户态执行时,进程所能访问的内存空间和对象受到限制,其所处于占有的处理机是可被抢占的 ;而处于核心态执行中的进程,则能访问所有的内存空间和对象,且所占有的处理机是不允许被抢占的。
hello的执行过程:hello初始运行为用户模式,当运行到sleep时将会陷入到内核模式,内核处理休眠请求主动释放当前进程,hello由运行状态转为等待状态,进行上下文切换将控制权交给sleep进程,当收到中断信号后,处于内核态的hello能够接受处理信号,将hello进程重新进入运行状态,之后再次运行到getchar时,实际落实到执行输入流是 stdin 的系统调用 read,hello再次陷入到内核态并进行数据传输接受用户的输入命令,时磁盘发出一个中断信号,此时内核从其他进程进行上下文切换回 hello 进程。
6.6 hello的异常与信号处理
首先介绍一下异常的种类
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自I/O设备信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复错误 | 同步 | 不返回 |
异步异常是由处理器外部的I/O设备中的事件产生的,同步异常是执行一条指令的产物。
中断是异步发生的,硬件中断不由任何指令造成,所以说是异步的。硬件中断的异常处理程序称为中断处理程序。
陷阱、故障和终止是同步发生的,称为故障指令。
陷阱是有意的异常,主要用来在用户程序和内核之间提供一个像过程一样的接口,称为系统调用。处理器提供了 syscall n 指令来满足用户向内核请求服务 n , syscall 指令会导致一个到异常处理程序的陷阱,处理程序调用适当的内核程序。普通函数运行在用户模式,而系统调用运行在内核模式。
故障由错误引起,如缺页异常。故障发生时,处理器将控制转移给故障处理程序,如果处理程序能够修正错误,就将控制返回到故障指令,重新执行;否则处理程序返回到内核的 abort 例程, abort 终止应用程序。
终止是不可恢复的致命错误的结果,主要是一些硬件错误。终止处理程序将控制返回到 abort 例程,abort 终止应用程序。
1》 正常执行最后被回收
2》 输出过程中不停乱按,只是在屏幕上显示出来并未对程序的输出造成影响
3》 当程序运行时按下ctrl+z,向内核发送信号SINGINT,陷入到内核态,处理信号,该命令为将进程挂起并显示处理结果,通过ps看出hello并未被回收,再次fg调用,程序继续执行
4》 当程序执行时按ctrl+c,向内核发送信号SINGINT,陷入到内核态,处理信号,终止程序
6.7本章小结
本章对进程进行了介绍,通过本章的总结,对shell工作的处理流程,了解了进程的概念和作用,以及fork以及execve的过程。并了解了hello的异常和信号处理
通过本章的学习,加深了对进程执行过程的理解
第7章 hello的存储管理
7.1 hello的存储器地址空间
-
物理地址: 用于内存芯片级的单元寻址,与地址总线相对应。这个概念应该是这几个概念中最好理解的一个,但是值得一提的是,虽然可以直接把物理地址理解成插在机器上那根内存本身,把内存看成一个从0字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址,但是事实上,这只是一个硬件提供给软件的抽像,内存的寻址方式并不是这样。所以,说它是“与地址总线相对应”,是更贴切一些,不过抛开对物理内存寻址方式的考虑,直接把物理地址与物理的内存一一对应,也是可以接受的。也许错误的理解更利于形而上的抽像。
-
虚拟地址: 这是对整个内存(不要与机器上插那条对上号)的抽像描述。它是相对于物理内存来讲的,可以直接理解成“不直实的”,“假的”内存,例如,一个0x08000000内存地址,它并不对就物理地址上那个大数组中0x08000000 - 1那个地址元素;有了这样的抽像,一个程序,就可以使用比真实物理地址大得多的地址空间。甚至多个进程可以使用相同的地址。不奇怪,因为转换后的物理地址并非相同的。
——可以把连接后的程序反编译看一下,发现连接器已经为程序分配了一个地址,例如,要调用某个函数A,代码不是call A,而是call
0x0811111111 ,也就是说,函数A的地址已经被定下来了。没有这样的“转换”,没有虚拟地址的概念,这样做是根本行不通的
-
逻辑地址: 可以认为是cpu执行程序过程中的一种中间地址。Intel为了兼容,将远古时代的段式内存的管理方式保留了下来,至于为什么会产生段式内存的管理方式,参见[2]。一个逻辑地址,是由一个段标识符加上一个指定段内的相对地址的偏移量,表示为[段标识符:段内偏移量],也就是说上面例子中的那个0x08111111应该表示为 [A的代码的段标识符:0x08111111] 这样才完整一些。
-
线性地址: 线性地址,也即虚拟地址,如果逻辑地址对应的是硬件平台段式管理转换前的地址的话,那么线性地址则对应了硬件页式内存的转换前的地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址是程序源码编译后所形成的跟实际内存没有直接联系的地址,即在不同的机器上,使用相同的编译器来编译同一个源程序,则其逻辑地址是相同的,但是相同的逻辑地址,在不同的机器上运行,其生成的线性地址又不相同,因为把逻辑地址转换成线性地址的公式是
线性地址=段基址*16+偏移的逻辑地址,而段基址由于不同的机器其任务不同,其所分配的段基址(线性地址)也会不相同,因此,其线性地址会不同。
即使,对于转换后线性地址相同的逻辑地址,也因为在不同的任务中,而不同的任务有不同的页目录表和页表把线性地址转换成物理地址,因此,也不会有相同的物理地址冲突。
注意的是,源码编译后生成的地址,只是偏移的地址,而形成逻辑地址的[段基址:偏移地址]中的段基址,是在生成任务时才定下来的,也就是说,[段基址:偏移地址]只有在进程中才会用到,在程序中只有偏移地址的概念。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址对应到物理地址是通过分页机制,具体的说,就是通过页表查找来对应物理地址。
准确的说分页是CPU提供的一种机制,Linux只是根据这种机制的规则,利用它实现了内存管理。
在保护模式下,控制寄存器CR0的最高位PG位控制着分页管理机制是否生效,如果PG=1,分页机制生效,需通过页表查找才能把线性地址转换物理地址。如果PG=0,则分页机制无效,线性地址就直接做为物理地址。
分页的基本原理是把内存划分成大小固定的若干单元,每个单元称为一页(page),每页包含4k字节的地址空间(为简化分析,我们不考虑扩展分页的情况)。这样每一页的起始地址都是4k字节对齐的。为了能转换成物理地址,我们需要给CPU提供当前任务的线性地址转物理地址的查找表,即页表(page table)。注意,为了实现每个任务的平坦的虚拟内存,每个任务都有自己的页目录表和页表。
为了节约页表占用的内存空间,x86将线性地址通过页目录表和页表两级查找转换成物理地址。
32位的线性地址被分成3个部分:
最高10位 Directory 页目录表偏移量,中间10位 Table是页表偏移量,最低12位Offset是物理页内的字节偏移量。
页目录表的大小为4k(刚好是一个页的大小),包含1024项,每个项4字节(32位),项目里存储的内容就是页表的物理地址。如果页目录表中的页表尚未分配,则物理地址填0。
页表的大小也是4k,同样包含1024项,每个项4字节,内容为最终物理页的物理内存起始地址。
每个活动的任务,必须要先分配给它一个页目录表,并把页目录表的物理地址存入cr3寄存器。页表可以提前分配好,也可以在用到的时候再分配。
还是以 mov 0x80495b0, %eax 中的地址为例分析一下线性地址转物理地址的过程。
前面说到Linux中逻辑地址等于线性地址,那么我们要转换的线性地址就是0x80495b0。转换的过程是由CPU自动完成的,Linux所要做的就是准备好转换所需的页目录表和页表(假设已经准备好,给页目录表和页表分配物理内存的过程很复杂,后面再分析)。
内核先将当前任务的页目录表的物理地址填入cr3寄存器。
线性地址 0x80495b0 转换成二进制后是 0000 1000 0000 0100 1001 0101 1011 0000,最高10位0000 1000 00的十进制是32,CPU查看页目录表第32项,里面存放的是页表的物理地址。线性地址中间10位00 0100 1001 的十进制是73,页表的第73项存储的是最终物理页的物理起始地址。物理页基地址加上线性地址中最低12位的偏移量,CPU就找到了线性地址最终对应的物理内存单元。
我们知道Linux中用户进程线性地址能寻址的范围是0 - 3G,那么是不是需要提前先把这3G虚拟内存的页表都建立好呢?一般情况下,物理内存是远远小于3G的,加上同时有很多进程都在运行,根本无法给每个进程提前建立3G的线性地址页表。Linux利用CPU的一个机制解决了这个问题。进程创建后我们可以给页目录表的表项值都填0,CPU在查找页表时,如果表项的内容为0,则会引发一个缺页异常,进程暂停执行,Linux内核这时候可以通过一系列复杂的算法给分配一个物理页,并把物理页的地址填入表项中,进程再恢复执行。当然进程在这个过程中是被蒙蔽的,它自己的感觉还是正常访问到了物理内存。
7.4 TLB与四级页表支持下的VA到PA的变换
虚地址(Virtual Address, VA)实地址(Physical Address, PA)转化,虚实地址转换主要分为两种:
由于整个系统的进程数不定,每个进程所需要的内存不定,以及进程切换的不确定性,因此,虚实地址转化不能简单的将某个连续大内存块映射到某个进程(Coarse-grained),必须采取更细粒度(Final-grained)的映射,即将一些可能不连续的小内存块(比如4K大小)一起映射到进程,形成一块连续的虚拟地址。为了记录这些映射信息,需要页表(Page)。但是页表的导入引入了新的问题,那就是每次访存变成了两次,一次查询页表,得到物理地址,第二次通过物理地址取数(事实上有办法把这两个过程部分并行起来,详见Cache的那篇)。为了提高查询页表的速度,现在的处理器都为页表做了一个小Cache,叫做旁路转换缓冲(Translation lookaside
buffer, TLB)。
直接映射,比如直接将64位的虚拟地址高位抹去,得到物理地址。这主要用于操作系统启动时的那块内存区域。主要是由于系统刚启动时,第1种转化所需要的页表,TLB没有初始化(页表,TLB其实都是操作系统管理的,倘若还用第一种,就陷入了鸡生蛋,蛋生鸡的死循环了),只能用这种最简单粗暴的办法。
由于第二种比较简单,在这里主要讲一下第1种虚实地址转化,即通过页表和TLB进行虚实地址转化。
用固定大小的页(Page)来描述逻辑地址空间,用相同大小的页框(Frame)来描述物理内存空间,由操作系统实现从逻辑页到物理页框的页面映射,同时负责对所有页的管理和进程运行的控制.用页表进行虚实地址转化的基本原理如下图:
首先,用虚地址的高位(叫做虚页号,Virtual Page Num,对应着一个小内存块)查询页表,得到其物理页框(Physical Page Frame)地址,然后用物理页框地址和虚地址的低位(偏移量,Page offset)得到物理地址。其中上面的偏移量决定了每个页表项的大小,在现代通用处理器中,一般为4K。
理论上,页表里面表项的数目和虚地址的高位数目有关,等于虚地址高位所能表示的最大值。因此,其数目非常可观,为了减少页表大小,出现了多级分页技术。
当进行虚实地址转化时,查询页表发现页表不在主存,就会出现缺页例外(Page
fault)。缺页中断需要操作系统把所需要的页表文件加载入主存,然后继续查询,这会消耗大量时间。
即使页表在主存中,查询也会消耗大量的时间,因此,利用局部性原理,现代处理器在其内部实现了一个页表的高速缓存,即TLB。当虚实地址转化时,先去TLB中查询页表是否存在,只有不存在时(此时发生TLB miss例外),再去主存中查询,当主存中还没有时,直接去物理存储查询(此时发生缺页例外)。
7.5 三级Cache支持下的物理内存访问
我们要先将高速缓存与地址翻译结合起来,首先是CPU发出一个虚拟地址给TLB里面搜索,如果命中的话就直接先发送到L1cache里面,没有命中的话就先在页表里面找到以后再发送过去,到了L1里面以后,寻找物理地址又要检测是否命中,这里就是使用到我们的CPU的高速缓存机制了,通过这种机制再搭配上TLB就可以使得机器在翻译地址的时候的性能得以充分发挥。
7.6 hello进程fork时的内存映射
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID 。并且创建hello进程的mm_struct
、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
当外壳运行一个程序时(在终端执行./hello),父进程生成一个子进程,它是父进程的一个复制品。子进程通过execve系统调用启动加载器。加载器首先删除子进程现有的虚拟存储器段,并创建一组新的代码、数据、堆和栈段。新的堆和栈段被初始化为零。随后会映射私有区域,为程序的文本、数据、bss和栈区域创建新的区域结构,其中文本和数据都是由程序文件本身提供的,也就是说,这部分的内容会被直接映像到虚拟地址空间,同样的,对于共享对象,比如.dll,.so的共享对象,也是由程序文件本身提供的,这些共享对象加载到物理存储器后,可能会被多个进程共享使用。不过对于栈和堆以及.bss区,这部分区域是由内核创建,习惯上我们称为匿名文件或者请求二进制零页面,在CPU第一次引用这部分区域的内容时,内核会在物理存储其中寻找一个合适的牺牲页面,并用匿名文件去替换牺牲页面同时更新页表。
当文件映射完成之后,加载器随后设置修改进程上下文的程序计数器,使其指向文本区域的入口点,加载器跳转到_start地址开始执行,它最终会调用应用程序的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到存储器数据拷贝,直到CPU引用一个被映射的虚拟页才会进行拷贝,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到存储器。
7.8 缺页故障与缺页中断处理
缺页中断:在请求分页系统中,可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存是,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。
缺页本身是一种中断,与一般的中断一样,需要经过4个处理步骤:
1、保护CPU现场
2、分析中断原因
3、转入缺页中断处理程序进行处理
4、恢复CPU现场,继续执行
7.9动态存储分配管理
在程序运行时程序员使
用 动态内存分配器 (比如 malloc) 获得虚拟内存(数据结构的大小只有运行 时才知道.)动态内存分配器维护着一个进程的虚拟内存区 域,称为堆。分配器将堆视为一组不同大小的块 (blocks)的集合来维 护,每个块要么是已分配的,要么是空闲的。分配器的类型有显示分配和隐式分配,前者要求应用显式地释放任何已分配的块,后者应用检测到已分配块不再被程序所使用,就释放这个块。
在分区的分配和回收时,根据不同的查找规则,有5种:
-
首次适配:从头开始搜索空闲列表,选择第一个合适的空闲块。易产生碎片。
-
下一次适配,从上一次查询结束的地方开始适配。速度较快但内存利用率低
-
最佳适配,检索整个空闲链表,选择大小合适的最小空闲块。但在使用隐式空闲链表时速度很慢。
7.10本章小结
通过本章的学习,我们了解到了hello的内存管理,对其中的存储地址空间,地址的转换, VA->PA,cache,fork的内存映射,execve的内存映射,缺页的处理与动态内存分配有了更深的了解
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。
8.2 简述Unix
IO接口及其函数
Linux/unix
IO的系统调用函数很简单,它只有5个函数:open(打开)、close(关闭)、read(读)、write(写)、lseek(定位)。但是系统IO调用开销比较大,一般不会直接调用,而是通过调用Rio包进行健壮地读和写,或者调用C语言的标准I/O进行读写。尽管如此,Rio包和标准IO也都是封装了unix I/O的,所以学习系统IO的调用才能更好地理解高级IO的原理。
- 打开文件 返回一个小的非负整数,即描述符。用描述符来标识文件。每个进程都有三个打开的文件:标准输入(0)、标准输出(1)、标准错误(2)
flags:进程打算如何访问文件
O_RDONLY:只读
O_WRONLY:只写 O_RDWR:可读可写
也可以是一个或更多位掩码的或:
O_CREAT:如文件不存在,则创建
O_TRUNC:如果文件已存在,则截断
O_APPEND:每次写操作,设置k到文件结尾
mode:指定新文件的访问权限位
每个进程都有一个umask,通过调用umask函数设置。所以文件的权限为被设置成mode & ~umask
-
改变当前文件位置 从文件开头起始的字节偏移量。系统内核保持一个文件位置k,对于每个打开的文件,起始值为0。应用程序执行seek,设置当前位置k,通过调用lseek函数,显示地修改当前文件位置。
-
读写文件。读操作:从文件拷贝n个字节到存储器,从当前文件位置k开始,将k增加到k+n,对于一个大小为m字节的文件,当k>=m时,读操作触发一个EOF的条件。写操作:从存储器拷贝n个字节到文件,k更新为k+n
a.
read函数:从描述符为fd的当前文件位置拷贝至多n个字节到存储器位置buf。返回-1表示一个错误,返回0表示EOF,否则返回实际读取的字节数。
b.
write函数:从存储器位置buf拷贝至多n个字节到描述符fd的当前文件位置。
- 关闭文件:内核释放文件打开时创建的数据结构,并恢复描述符到描述符池中,进程通过调用close函数关闭一个打开的文件。关闭一个已关闭的描述符会出错。
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;
}
代码位置:D:/~/funny/kernel/printf.c
在形参列表里有这么一个token:…
这个是可变形参的一种写法。
当传递参数的个数不确定时,就可以用这种方式来表示。
很显然,我们需要一种方法,来让函数体可以知道具体调用时参数的个数。
先来看printf函数的内容:
这句:
a_list arg = (va_list)((char*)(&fmt) + 4);
va_list的定义:
typedef char *va_list
这说明它是一个字符指针。
其中的: (char*)(&fmt) + 4) 表示的是…中的第一个参数。
如果不懂,我再慢慢的解释:
C语言中,参数压栈的方向是从右往左。
也就是说,当调用printf函数的适合,先是最右边的参数入栈。
fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。
fmt也是个变量,它的位置,是在栈上分配的,它也有地址。
对于一个char *类型的变量,它入栈的是指针,而不是这个char *型变量。
换句话说:
你sizeof§ (p是一个指针,假设p=&i,i为任何类型的变量都可以)
得到的都是一个固定的值。(我的计算机中都是得到的4)
当然,我还要补充的一点是:栈是从高地址向低地址方向增长的。
ok!
现在我想你该明白了:为什么说(char*)(&fmt) + 4) 表示的是…中的第一个参数的地址。
下面我们来看看下一句:
i = vsprintf(buf, fmt, arg);
让我们来看看vsprintf(buf, fmt, arg)是什么函数。
int vsprintf(char *buf, const char *fmt, va_listargs)
{
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);
}
我们还是先不看看它的具体内容。
想想printf要左什么吧
它接受一个格式化的命令,并把指定的匹配的参数格式化输出。
ok,看看i =vsprintf(buf, fmt, arg);
vsprintf返回的是一个长度,我想你已经猜到了:是的,返回的是要打印出来的字符串的长度
其实看看printf中后面的一句:write(buf, i);你也该猜出来了。
write,顾名思义:写操作,把buf中的i个元素的值写到终端。
所以说:vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
我代码中的vsprintf只实现了对16进制的格式化。
你只要明白vsprintf的功能是什么,就会很容易弄懂上面的代码。
下面的write(buf, i);的实现就有点复杂了
如果你是os,一个用户程序需要你打印一些数据。很显然:打印的最底层操作肯定和硬件有关。
所以你就必须得对程序的权限进行一些限制:
让我们假设个情景:
一个应用程序对你说:os先生,我需要把存在buf中的i个数据打印出来,可以帮我么?
os说:好的,咱俩谁跟谁,没问题啦!把buf给我吧。
然后,os就把buf拿过来。交给自己的小弟(和硬件操作的函数)来完成。
只好通知这个应用程序:兄弟,你的事我办的妥妥当当!(os果然大大的狡猾 _)
这样 应用程序就不会取得一些超级权限,防止它做一些违法的事。(安全啊安全)
让我们追踪下write吧:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
位置:d:~/kernel/syscall.asm
这里是给几个寄存器传递了几个参数,然后一个int结束
想想我们汇编里面学的,比如返回到dos状态:
我们这样用的
mov ax,4c00h
int 21h
为什么用后面的int 21h呢?
这是为了告诉编译器:号外,号外,我要按照给你的方式(传递的各个寄存器的值)变形了。
编译器一查表:哦,你是要变成这个样子啊。no problem!
其实这么说并不严紧,如果你看了一些关于保护模式编程的书,你就会知道,这样的int表示要调用中断门了。通过中断门,来实现特定的系统服务。
我们可以找到INT_VECTOR_SYS_CALL的实现:
init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call,
PRIVILEGE_USER);
位置:d:~/kernel/protect.c
如果你不懂,没关系,你只需要知道一个int
INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。(从上面的参数列表中也该能够猜出大概)
好了,再来看看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
位置:~/kernel/kernel.asm
一个call save,是为了保存中断前进程的状态。
太复杂了,如果详细的讲,设计到的东西实在太多了。
我只在乎我所在乎的东西。sys_call实现很麻烦,我们不妨不分析funny os这个操作系统了
先假设这个sys_call就一单纯的小女孩。她只有实现一个功能:显示格式化了的字符串。
这样,如果只是理解printf的实现的话,我们完全可以这样写
sys_call:
sys_call:
;ecx中是要打印出的元素个数
;ebx中的是要打印的buf字符数组中的第一个元素
;这个函数的功能就是不断的打印出字符,直到遇到:'\0'
;[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
xor si,si
mov ah,0Fh
mov al,[ebx+si]
cmp al,'\0'
je .end
mov [gs:edi],ax
inc si
loop:
sys_call
.end:
ret
(参考)https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
通过本章hello的IO管理的学习了解了linux的IO设备管理方法,Unix IO接口以及五个主要函数open(打开)、close(关闭)、read(读)、write(写)、lseek(定位)的功能以及函数的结构,并分析了printf与getchar的实现,
结论
至此hello的一生终于结束了,将hello的人生转折点总结一下:
-
编写:程序员通过键盘鼠标等IO设备敲入代码(心疼以后每天敲代码的自己)
-
预处理:对程序员敲入的hello.c文件进行预处理生成hello.i
-
编译:编译器将hello.i编译生成hello.文件
-
汇编:汇编器对hello.s进行汇编处理生成可重定位的hello.o文件
-
链接: 将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello
-
运行:在shell中输入./hello 1170300527 吴昊
-
创建子进程:shell为其fork子进程
-
运行程序:调用execve将程序加载进去
-
创建新的内存区域,并创建一组新的代码、数据、堆和栈段。并安排好内容
10.对异常,信号,命令进行处理
11.进程的回收杀死进程
Hello坎坷的一生终于结束了,别看hello只是一个简单的只有几十行代码的小程序但其执行过程却如此的复杂,以前一直认为像hello word这样的程序太简单了,甚至在创建项目时编译器可以自动生成,而通过本次的总结才真正认识到了即使最简单的一个hello程序其执行处理的过程也是非常复杂的,要想真正理解他要花费很大的功夫,在学习计算机的过程中不能只是会简单的敲击代码,还要真正理解程序的执行过程,了解程序最底层的原理才能真正学好计算机.
通过本门课程的学习也只是对深入理解计算机系统这本书有了初步的了解,作为计算机的神书之一,以后有时间一定要进行更深度的研读
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名称 | 文件作用 |
---|---|
hello.c | hello原程序代码 |
hello.i | 预处理结果 |
hello.s | 编译之后的汇编结果 |
hello.o | 汇编后的可重定位执行文件 |
hello | 链接之后的可执行最终程序 |
helloas.txt | hello.o的反汇编代码 |
helloELF.txt | hello.o的elf格式文件 |
hello.txt | hello的反汇编代码 |
helloelf.txt | hello的elf格式文件 |
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴.
空间控制技术[M].
北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C].
北京:中国科学出版社,1999.
[3] 赵耀东.
新时代的工业工程师[M/OL].
台北:天下文化出版社,1998
[1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖.
空间交会控制理论与方法研究[D].
哈尔滨:哈尔滨工业大学,1992:8-13.
[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.