摘 要
本文主要介绍了hello程序在Linux系统中的生命周期。我们将结合《深入理解计算机系统》中的内容,通过研究分析hello.c经过预处理、编译、汇编、链接生成可执行文件的过程,以及计算机系统对hello可执行目标程序的进程、存储以及I/O管理,让读者对计算机系统的主要结构和基本原理有一个更加清晰的认识。
关键词:hello;Linux;计算机系统;处理器体系结构;存储器体系结构;进程
目 录
第1章 概述... - 5 -
1.1 Hello简介... - 5 -
1.2 环境与工具... - 5 -
1.3 中间结果... - 5 -
1.4 本章小结... - 6 -
第2章 预处理... - 7 -
2.1 预处理的概念与作用... - 7 -
2.2在Ubuntu下预处理的命令... - 7 -
2.3 Hello的预处理结果解析... - 7 -
2.4 本章小结... - 9 -
第3章 编译... - 10 -
3.1 编译的概念与作用... - 10 -
3.2 在Ubuntu下编译的命令... - 10 -
3.3 Hello的编译结果解析... - 10 -
3.3.1 汇编指示... - 10 -
3.3.2 数据... - 11 -
3.3.3 算术操作... - 12 -
3.3.4 关系操作和控制转移... - 12 -
3.3.5 数组/指针/结构操作... - 13 -
3.3.6 函数操作... - 13 -
3.4 本章小结... - 16 -
第4章 汇编... - 17 -
4.1 汇编的概念与作用... - 17 -
4.2 在Ubuntu下汇编的命令... - 17 -
4.3 可重定位目标elf格式... - 17 -
4.3.1 命令... - 17 -
4.3.2 ELF头(ELF Header)... - 17 -
4.3.3 节头部表(Section Headers)... - 18 -
4.3.4 重定位节(Relocation section)... - 19 -
4.3.5 符号表(Symbol table)... - 20 -
4.4 Hello.o的结果解析... - 20 -
4.5 本章小结... - 23 -
第5章 链接... - 24 -
5.1 链接的概念与作用... - 24 -
5.2 在Ubuntu下链接的命令... - 24 -
5.3 可执行目标文件hello的格式... - 24 -
5.3.1 命令... - 24 -
5.3.2 ELF头(ELF Header)... - 24 -
5.3.3 节头部表(Section Headers)... - 25 -
5.3.4 重定位节(Relocation section)... - 27 -
5.3.5 符号表(Symbol table)... - 27 -
5.4 hello的虚拟地址空间... - 28 -
5.5 链接的重定位过程分析... - 29 -
5.5.1 命令... - 29 -
5.5.2 hello和hello.o的不同... - 29 -
5.5.3 hello的重定位过程... - 30 -
5.6 hello的执行流程... - 31 -
5.7 Hello的动态链接分析... - 32 -
5.8 本章小结... - 33 -
第6章 hello进程管理... - 34 -
6.1 进程的概念与作用... - 34 -
6.2 简述壳Shell-bash的作用与处理流程... - 34 -
6.3 Hello的fork进程创建过程... - 35 -
6.4 Hello的execve过程... - 35 -
6.5 Hello的进程执行... - 36 -
6.6 hello的异常与信号处理... - 38 -
6.7本章小结... - 40 -
第7章 hello的存储管理... - 41 -
7.1 hello的存储器地址空间... - 41 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 41 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 43 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 44 -
7.5 三级Cache支持下的物理内存访问... - 46 -
7.6 hello进程fork时的内存映射... - 47 -
7.7 hello进程execve时的内存映射... - 47 -
7.8 缺页故障与缺页中断处理... - 48 -
7.9动态存储分配管理... - 49 -
7.10本章小结... - 52 -
第8章 hello的IO管理... - 53 -
8.1 Linux的IO设备管理方法... - 53 -
8.2 简述Unix IO接口及其函数... - 53 -
8.3 printf的实现分析... - 55 -
8.4 getchar的实现分析... - 56 -
8.5本章小结... - 57 -
结论... - 57 -
附件... - 59 -
参考文献... - 60 -
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
1、P2P:从program到process的过程。
(1)Program:在editor中编辑输入代码获得hello.c文件。
(2)Process:在linux当中,hello.c文件通过cpp的预处理、ccl的编译器、as的汇编器以及ld的链接,最终成为一个可执行目标程序hello。然后,在shell中键入启动命令后,shell调用fork为其产生一个子进程。
2、020:
(1)shell为 hello进程execve,映射虚拟内存,进入程序入口后程序开始载入物理内存。
(2)进入main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。
(3)当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。
硬件环境:X64 CPU;2.60 G Hz;8.00 GB RAM;512 G HDD
软件环境:Windows10 64 位;VMware 15.5.0;Ubuntu Desktop 18.04
开发与调试工具:gcc,as,ld,vim,edb,readelf,VScode
文件名 |
文件作用 |
hello.i |
hello.c预处理后的文本文件 |
hello.s |
hello.i编译后的汇编文件 |
hello.o |
hello.s汇编后的可重定位目标文件 |
hello |
hello.o链接后的可执行目标文件 |
elf.txt |
hello.o的ELF格式 |
Helloelf.txt |
hello的ELF格式 |
Obj_hello |
hello.o的反汇编代码 |
Obj_helloo |
hello的反汇编代码 |
本章主要介绍了hello的P2P和020的具体含义和过程,列出了作业中配置的软硬件环境、开发和调试工具以及中间过程中产生的一些文件。后文将对以本章作为总领进行具体详细的展开。
1、预处理的概念
预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,修改原始的C程序,其中 ISO C/C++要求支持的包括#if、 #ifdef、 #ifndef、 #else、 #elif、 #endif(条件编译)、 #define(宏定义)、 #include(源文件包含)、 #line(行控制)、 #error(错误指令)、 #pragma(和实现相关的杂注)以及单独的#(空指令)。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。
2、预处理的作用
(1)处理头文件包含指令:将源文件中用#include形式声明的头文件内容复制到程序中。例如hello.c中的#include
(2)处理宏定义指令:用实际值替换#define定义的字符串。
(3)处理条件编译指令:根据条件编译指令#if、#ifdef等来决定需要编译那些代码,并删除不需要编译的代码。
(4)处理特殊符号:预编译程序可以识别一些特殊的符号,预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
(5)去除注释。
命令:gcc hello.c -E -o hello.
打开hello.i文件发现源程序被修改为3105行,hello.c的main程序出现在3091行之后。由于.c程序中main函数前只有“#include
本章介绍了预处理的相关概念以及其具体作用,如对头文件包含指令、宏定义指令、条件编译指令、特殊符号的处理,并结合对hello.c的预处理,通过分析hello.i文件详细地阐述了预处理的内涵。
1、编译的概念
编译就是编译器通过词法和语法分析,将代码指令转换为等价的中间代码或者汇编代码,将.i文件翻译成.s文件,它包含了一个汇编语言程序。该例中就是将文本文件hello.i翻译成文本文件hello.s。
2、编译的作用
编译程序的基本功能是把源程序(高级语言)翻译成目标程序。但是作为一个具有实际应用价值的编译系统,除了基本功能之外,还应具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人-机联系等重要功能。
命令:gcc -S hello.i -o hello.s
.file:声明源文件
.text:已编译程序的机器代码
.section:划分代码段
.rodata:只读数据
.align:地址对齐方式
.string:声明字符串
.global:声明全局变量
.type:声明符号类型
1、常量
(1)字符串
在程序开始声明了两个字符串,存储在.rodata只读数据段中,作为printf函数的两个传入参数。
(2)立即数
程序中的立即数被存储在.text代码段中,直接作为代码的一部分。例如“if(argc!=4)”中的4,“for(i=0;i<8;i++)”中的0和8,“argv[1],argv[2]”中的1和2。
2、变量
(1)全局变量和静态变量
初始化的全局变量和静态变量存储在.data段中,未初始化或初始化为0的全局变量和静态变量存储在.bss段中,这个节不占据实际空间,仅是一个占位符。
(2)局部变量
程序中声明了一个局部变量i,局部变量会被存放在寄存器或者堆栈中,这里i被存放在-4(%rbp)的位置。
(1)在for循环中使用了++操作符,使每次循环变量i都会+1。
在汇编代码中的实现,为-4(%rbp)处(i的存储位置)的数据+1。
(2)在for循环中使用了=进行i的初始化赋值,将其值赋为0。
在汇编代码中使用movl指令实现。
(1)源代码中使用if语句和!=符号判断argc是否为4,是则输出一段字符串。
汇编代码中,利用cmpl指令进行argc和4的比较,同时用je指令进行条件跳转来实现if语句。
(2)源代码中for循环的执行
汇编代码中,利用cmpl指令实现“i<8”的判断,同时用jle指令进行条件跳转来实现if语句。
main函数的参数列表中有指针数组char *argv[]。在该数组中,argv[0]指向程序的路径和名称,argv[1]和argv[2]指向两个传给main的字符串。
通过对汇编代码中,对printf函数的传参过程可以分析得出,argv[1]和argv[2]分别存储在-24(%rbp)和-16(%rbp)的位置。
调用函数时进行的操作:
传递参数:int argc, char *argv[],分别存放在%rdi和%rsi中。
函数调用:被系统启动函数调用。
函数返回:将%eax设置为0返回,故返回值为0。
传递参数:第一次call puts时,传入字符串LC0的首地址;第二次call printf时,传入位于LC1的argv[1]和argv[2],即两个字符串,同时需要将%eax置零。
函数调用:被main函数在if和for语句中调用。
call puts:
call printf:
传递参数:传入立即数1。
函数调用:被main函数在if语句中调用。
汇编实现:
传递参数:argv[3]
函数调用:被main函数在for语句中调用,返回值作为sleep函数的传入参数。
汇编实现:
传递参数:传入函数atoi(argv[3])的返回值。
函数调用:被main函数在for语句中调用。
汇编实现:
传递参数:无
函数调用:在main函数中被调用。
汇编实现:
本章主要介绍了编译的相关概念以及其具体过程。我们通过对hello.i编译成hello.s的过程以及hello.s内容的探究,具体分析了汇编代码对各类数据、算术操作、关系操作、控制转移、数组/指针/结构操作以及函数操作的实现,通过源代码与C源代码的对比阅读帮助读者对汇编语言有了更好的理解。
1、汇编的概念
通过汇编器(as)将汇编程序翻译成与之等价的机器语言并得到一个可重定位目标程序的过程。
2、汇编的作用
汇编器(as)首先检查汇编程序(.s文件)语法的正确性,若正确,则将其翻译成与之等价的机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件(.o二进制文件)中。
命令:gcc hello.s -c -o hello.o
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
readelf -a hello.o > elf.txt
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统 的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解 释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、 字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
节头部表描述了各个节的类型、地址、大小等信息。因为是可重定位目标程序,每个节都是从0开始。在文件头中得到节头部表的信息,再利用节头部表的字节偏移信息(Offset)得到各个节的初始位置以及每个节的大小,同时可以知道是否可执行、读写等信息。
重定位节是一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息。在链接时,需要通过重定位节对各个段应用的外部符号等的地址进行修改。
符号表存放在程序中定义和引用的函数和全局变量的信息。其中,name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字;value是符号的地址。对于可定位目标文件来说,value是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行的地址。size是目标的大小(以字节为单位),type通常要么是数据要么是函数。bind字段表明符号是本地的还是全局的。
命令:objdump -d -r hello.o > obj_helloo
hello.o的反汇编
hello.s
分析hello.o的反汇编,并与第3章的 hello.s进行对照分析:
本章对汇编的相关概念以及汇编得到的文件进行了探究。我们将hello.s进行汇编得到hello.o可重定位目标程序,这为接下来的链接做好了准备。同时也分析了ELF头、节头部表、重定位节以及符号表。最后,将hello.o的反汇编程序与hello.s进行对比,更加深刻地阐释了从汇编语言到机器级语言转变的过程。
1、链接的概念
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序自动执行的。
2、链接的作用
链接器在软件开发中扮演者一个关键的角色,因为它们使得分离编译成为可能。我们可以吧一个大型的应用程序分解为小的、好管理的模块,我们可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需要简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
命令: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
readelf -a hello > helloelf.txt
与hello.o的头文件不同,hello中Type为EXEC(Executable file) ,即hello为一个可执行目标文件,根据Number of section headers可知有25个节。
节头部表声明了各个节的类型、地址、偏移量等信息,通过这些信息,就可以定位到各个节所在的区域。链接器链接时,会将各个文件的相同段合并,并且根据合并段的大小以及偏移量重新设置各个符号的地址。
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
通过查看edb,可知hello的虚拟地址空间开始与0x400000,终止于0x400ff0。
查看 ELF 格式文件中的 Program Headers,它告诉链接器运行时加载的内容,并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的各方面的信息,包含PHDR,INTERP,LOAD ,DYNAMIC,NOTE ,GNU_STACK,GNU_RELRO几个部分。
其中PHDR 保存程序头表。INTERP 指定在程序已经从可执行文件映射到内存之后,必须调用的解释器。LOAD 表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等。DYNAMIC 保存了由动态链接器使用的信息。NOTE 保存辅助信息。GNU_STACK:权限标志,用于标志栈是否是可执行。GNU_RELRO:指定在重定位结束之后哪些内存区域是需要设置只读。
objdump -d -r hello > obj_hello
1、增加函数
相比hello.o,hello中增加了hello.c源文件所用到的库函数(如printf、getchar、atoi、exit等)的具体实现。
2、增加节
hello中添加了节,如.init、.text、.fini,还加入一些节中定义的函数。
3、地址访问和函数调用
由于hello.o中的函数只有在链接后才能确定具体的地址,所以要在.rela.text节中为其添加重定位条目。而链接后的hello中没有重定位条目,跳转和函数调用的地址都是虚拟内存地址(绝对地址)。
1、重定位节和符号定义
链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
2、重定位节中的符号引用
链接器修改代码节和数据节中对每个符号的引用,使它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
3、重定位条目
当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。
重定位条目格式
4、重定位地址计算算法
使用edb执行hello,逐步执行并记录下call命令调用的所有函数。
函数名 |
函数地址 |
ld-2.27.so!_dl_start |
0x7fdf43bacea0 |
ld-2.27.so!_dl_init |
0x7fdf43bbb7d2 |
hello!_start |
0x400550 |
libc-2.27.so!__libc_start_main |
0x7fdf437dbb10 |
-libc-2.27.so!__cxa_atexit |
0x7fdf437fd550 |
-libc-2.27.so!__new_exitfn |
0x7fdf437fd340 |
-libc-2.27.so!__libc_csu_init |
0x400610 |
hello!init |
0x4004c0 |
libc-2.27.so!_setjmp |
0x7fdf437f8d30 |
libc-2.27.so!_sigjump_save |
0x7fdf437f8cf0 |
Hello!main |
0x400582 |
Hello!puts@plt |
0x4004f0 |
Hello!exit@plt |
0x400530 |
libc-2.27.so!exit |
0x7fdf437fd240 |
GOT(全局偏移量表)中,每个被目标模块引用的全局数据目标(过程或全局变量)都有一个8字节条目。编译器还为GOT中每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。每个引用全局目标的目标模块都有自己的GOT。
PLT(过程链接表)是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。PLT[1]调用系统启动函数(__libc_start_main),它初始化执行环境,调用main函数并处理其返回值。从plt[2]开始的条目调用用户代码调用的函数。
在helloelf.txt中查找到GOT和PLT。
在edb中找到对应的起始地址:
调用dl_init之前
调用dl_init之后
调用dl_init前后,0x601008开始之后的两个8字节发生了变化,分别从0变为了0x7fc24a601170和0x7fc24a3ed8f0。
对于变量而言,我们利用代码段和数据段的相对位置不变的原则计算正确地址。对于库函数而言,需要plt、got合作,plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。plt就能跳转到正确的区域。
本章主要介绍了链接的相关概念和其作用,探究了hello.o是如何通过链接成为一个可执行目标文件的,并分析了hello的ELF格式和各个节的含义,以及hello的虚拟地址空间、重定位过程、执行流程和动态链接。
1、进程的概念
进程的经典定义就是一个执行中程序的实例。
2、进程的作用
进程提供给应用程序两个关键抽象:
1、shell含义
shell是一个交互型应用级程序,代表用户运行其他程序,是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。
2、作用
shell应用程序提供了一个界面,用户通过访问这个界面访问操作系统内核的服务。
shell也是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的Shell程序与其他应用程序具有同样的效果。
3、处理流程
(1)从终端读入用户输入的命令;
(2)将输入字符串切分获得所有的命令行参数,并传参给execve执行函数;
(3)判断首个命令行参数是否为内置的shell命令,如果是内置命令,则立即执行;
(4)如果不是内置命令,则调用fork函数创建新的子进程;
(5)在子进程中,重复步骤(2)调用execve函数执行指定程序;
(6)若用户未要去后台运行,则调用wait或waitpid等待进程终止后返回;
(7)若用户要求后台运行,则shell返回。
终端通过调用fork函数创建一个子进程,子进程得到与父进程用户级虚拟地址空间完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流中的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
当在shell中输入指令“./hello 1190201008 周凡 1 0”后,shell首先解析这段指令,然后发现这不是一个shell的内置命令,于是调用fork创建一个子进程运行之。
execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。该函数的作用就是在当前进程的上下文中加载并运行一个新的程序。只有当出现错误时,例如找不到Hello时,execve才会返回到调用程序。
在execve加载了Hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数(int main(intargc , char **argv , char *envp);)。
对于exceve函数加载和执行程序hello的具体过程有如下几步:
图6.4加载器是如何映射用户地址空间的区域的
1、逻辑控制流
一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流 使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占 (暂时挂起),然后轮到其他进程。
2、并发流
(1)一个逻辑流的执行时间与另一个流重叠,称为并发流,这两个流被称为并发地运行。
(2)多个流并发地执行的一般现象成为并发。
(3)一个进程执行它的控制流的一部分的每一时间段叫做时间片。
3、私有地址空间
进程为每个流都提供一种假象,好像它是独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上,这个地址空间是私有的。
4、用户模式和内核模式
处理器通常是用某个控制寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权。
当设置了模式位时,进程就运行在内核模式中,该进程可以执行指令集中的任 何命令,并且可以访问系统中的任何内存位置。
当没有设置模式位时,进程就运行在用户模式, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。
5、上下文切换
上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值组成。
当内核选择一个新的进程运行时,则称内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:
(1)保存以前进程的上下文;
(2)恢复新恢复进程被保存的上下文;
(3)将控制传递给这个新恢复的进程,来完成上下文切换。
6、hello的进程执行
输入“./hello 1190201008 周凡 1 0”指令,进程调用execve函数,且为hello程序分配虚拟地址空间,并将hello的.txt和.data分别存放到虚拟地址空间的代码区和数据区。然后执行程序。
起初,该进程运行在用户模式下,输出“hello 1190201008 周凡”,然后会调用sleep函数,进而陷入到内核。内核会处理休眠请求并将hello进程从运行队列移到等待队列中,定时器开始计时,接下来内核进行上下文切换将当前进程控制权转交给其他进程。当到达指定时间时,定时器发送一个中断信号,内核执行中断处理,将hello再移回运行队列,继续运行hello进程。
图6.5.1进程上下文切换(sleep)
当hello进程调用getchar函数时,会调用read。hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,发出一个中断信号,表示数据传输完成,内核再从其他进程进行上下文切换回hello进程。
图6.5.2 运行输出结果
1、异常
类别 |
原因 |
异步/同步 |
返回行为 |
中断 |
来自I/O设备的信号 |
异步 |
总是返回下一条指令 |
陷阱 |
有意的异常 |
同步 |
总是返回下一条指令 |
故障 |
潜在可恢复的错误 |
同步 |
可能返回到当前指令 |
终止 |
不可恢复的错误 |
同步 |
不会返回 |
hello程序可能会出现的异常:
2、信号
hello程序可能需要处理的一些信号:
ID |
名称 |
默认行为 |
相应事件 |
2 |
SIGINT |
终止 |
来自键盘的中断 |
9 |
SIGKILL |
终止 |
杀死进程(该信号不能被捕获和加载) |
11 |
SIGSEGV |
终止 |
无效的内存引用(段故障) |
14 |
SIGALRM |
终止 |
来自alarm函数的定时器信号 |
17 |
SIGCHLD |
忽略 |
一个子进程停止或者终止 |
3、实际运行
(1)正常运行
(2)按下ctrl-z。进程收到SIGSTP信号,hello进程挂起,但进程未被回收。使用ps命令查看进程PID,发现存在hello进程,PID为2280。使用jobs查看到该进程job号为1,使用“fg 1”指令使该进程回到前台继续运行。
(3)按下ctrl-c。进程收到 SIGINT 信号,结束 hello。在ps中查询发现已经没有hello进程了。
(4)中途乱按。输入会被当做指令执行,但乱码没有对应指令,所以只是简单地输出到屏幕上。
(5)执行kill命令。先ctrl-z挂起hello,通过ps查看得hello的PID为2325。通过kill指令杀死2325进程,再次ps查看发现hello进程已经没有了。
本章介绍了进程的相关概念和作用以及shell的一般处理流程,并且通过结合hello实例具体详细地分析了shell如何调用fork创建新的进程,调用execve函数执行程序,进程执行中模式和上下文的切换以及对异常和信号的处理,使读者对进程和linux如何执行程序有了更加清晰的认识。
1、逻辑地址
逻辑地址是由程序产生的与段相关的偏移地址部分。在hello中表现为hello程序产生的与段相关的偏移地址部分。
2、线性地址
线性地址是逻辑地址到物理地址变换之间的中间层。hello程序的代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
3、虚拟地址
虚拟地址等价于线性地址。
4、物理地址
是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,表示具体的是代码段寄存器还是栈段寄存器抑或是数据段寄存器,如图7.2.1所示。
图7.2.1端选择符
索引号就是“段描述符(segment descriptor)”的索引,段描述符具体地址描述了一个段。很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符。如图7.3.2所示。
图7.2.2段选择符
Base字段,表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,就放在所谓的“局部段描述符表(LDT)”中。那究竟什么时候该用GDT,什么时候该用LDT呢?这是由段选择符中的T1字段表示的,为0表示用GDT,为1表示用LDT,GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
图7.2.3详细显示了一个逻辑地址是怎样转换成相应线性地址的,给定一个完整的逻辑地址[段选择符:段内偏移地址]:
图7.2.3逻辑地址的转换
1、含义及实现原理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。
将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
图7.3.1页表
图7.3.2使用页表的地址翻译
2、优点
3、缺点
探究Intel Core i7环境下(采用四级页表层次结构)研究VA到PA的变换。Intel Core i7实现支持48位虚拟地址空间和52位物理地址空间,采用四级页表,TLB 4路16组相联。CR3控制寄存器指向第一级页表的起始位置。其中,一个页表4KB,一个PTE条目8B,共512个条目,使用9位二进制索引,一共4个页表。
如图 7.4.1,CPU将虚拟地址VA传送给MMU,MMU使用前36位VPN作为 TLBT(前32位)+TLBI(后4位)并在TLB中匹配。若命中,则得到PPN(40位)与VPO(12为)组成 PA(52位);若未命中,则MMU到页表中查询,通过CR3 确定第一级页表的起始地址,VPN1(9位)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,重复此操作直到最终在第四级页表中查询到PPN,与VPO组合成PA,并且在TLB中添加条目。如果查询PTE时发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
图7.4.1 Core i7地址翻译
图7.4.2第一级、第二级和第三级表条目格式
图7.4.3第四级表条目格式
一级Cashe有64组,故组索引位s为6,每组有8个高速缓存行;每个块的大小为64B,故块偏移位b为6,因此标记位t为52-6-6=40。
一级Cashe的物理访存过程如下:
(1)根据地址值中的组索引位找到相应的组。
(2)将虚拟地址的标记位与相应的组中所有行的标记位进行比较,当两者匹配且高速缓存行的有效位是1时,则高速缓存命中。
(3)缓存命中后,根据块偏移位得出偏移量,找到第一个字节,把这个字节的内容取出传给CPU。
(4)若缓存不命中,则需要从存储层次结构中的下一层,即二级cache取出被请求的块,然后将新的块存储在组索引位所指示的组中的一个高速缓存行中。如果组内有空闲块,则直接放置;否则可以采用LFU策略。
(5)在二、三级cache中的物理访存流程与一级完全一致,且在缓存不命中时都需要从下一层中取出请求块,直到缓存命中。
图7.5物理内存访问
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。
execve 函数在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用hello程序有效地替代了当前程序。 加载并运行hello需要以下几个步骤:
下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
图7.7加载器是如何映射用户地址空间的区域的
处理缺页是由硬件和操作系统内核协作完成,如图7.8所示。
图7.8缺页处理
处理流程:
动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆,如图7.9.1。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
图7.9.1 堆
分配器有两种基本风格。两种风格都是要求显示的释放分配块。
(1)显式分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。其约束条件有:处理任意的请求序列;立即相应请求;只使用堆;对其块(对齐要求);不修改已分配的块。
(2)隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器,而自动释放未使用的已分配的块的过程佳作垃圾收集。
接下来讨论几种动态内存管理的策略。
1、隐式空闲链表
图7.9.2 一种隐式空闲链表堆块的格式
如图7.9.2位一种隐式空闲链表堆块的格式,用于区别块边界以及已分配块和空闲块。
这种情况下,一个块是由一个字的头部、有效载荷,以及可能的填充组成。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。块的头最后一位指明这个块是已分配的还是空闲的。
头部后面是应用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。块的格式如图7.9.3所示,空闲块通过头部块的大小字段隐含的连接着,所以我们称这种结构就隐式空闲链表。
图7.9.3隐式空闲链表块的格式
(1)放置已分配的块
当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。分配器搜索方式的常见策略是首次适配、下一次适配和最佳适配。
(2)分割空闲块
一旦分配器找到一个匹配的空闲块,就必须做一个另策决定,那就是分配这个块多少空间。分配器通常将空闲块分割为两部分。第一部分变为了已分配块,第二部分变为了空闲块。如图7.9.4所示。
图7.9.4分割空闲块
(3)获取额外堆内存
如果分配器不能为请求块找到空闲块,一个选择是合并那些在物理内存上相邻的空闲块,如果这样还不能生成一个足够大的块,分配器会调用sbrk函数,向内核请求额外的内存。
(4)合并空闲块
为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块,分配器可以选择立即合并或者推迟合并等策略。
2、显示空闲链表
显示空闲链表是将空闲块组织为某种形式的显示数据结构,例如图7.9.5。堆被组织为一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继的指针。
图7.9.5使用边界标记的堆块的格式
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
一种方法使用后进先出的顺序维护链表,将新释放的块在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。
按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
本章主要介绍了存储器地址空间的相关概念,比较了逻辑地址、线性地址、虚拟地址和物理地址四种地址空间概念的差别并结合hello程序讨论了不同地址之间的相互转换。此外,还阐述了三级cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容。
一个Linux文件就是一个m字节的序列:
B0,B1,B2……Bm-1
1、设备的模型化:文件
所有的 IO 设备(如网路、磁盘、终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。
2、设备管理:unix io接口
这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都被当做相应文件的读和写来执行。
1、open()函数
(1)功能描述
用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。
(2)函数原型:int open(const char *pathname,int flags,int perms)
(3)参数
pathname//被打开的文件名(可包括路径名如"dev/ttyS0")
flags//文件打开方式
(4)返回值:成功返回文件描述符,失败返回-1。
2、close()函数
(1)功能描述
用于关闭一个被打开的的文件。
(2)所需头文件:#include
(3)函数原型:int close(int fd)
(4)参数
fd文件描述符
(5)函数返回值:成功返回0,错误返回-1。
3、read()函数
(1)功能描述
从文件读取数据。
(2)所需头文件:#include
(3)函数原型:ssize_t read(int fd, void *buf, size_t count);
(4)参数
fd//将要读取数据的文件描述词
buf//指缓冲区,即读取的数据会被放到这个缓冲区中去
count//表示调用一次read操作,应该读多少数量的字符
(5)返回值:返回所读取的字节数;读到EOF返回0;错误返回-1。
4、write()函数
(1)功能描述
向文件写入数据。
(2)所需头文件: #include
(3)函数原型:ssize_t write(int fd, void *buf, size_t count);
(4)返回值:成功返回写入文件的字节数;错误返回-1。
5、lseek()函数
(1)功能描述
用于在指定的文件描述符中将将文件指针定位到相应位置。
(2)所需头文件:#include
(3)函数原型:off_t lseek(int fd, off_t offset,int whence);
(4)参数
fd//文件描述符
offset//偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)
(5)返回值:成功返回当前位移,失败返回-1。
1、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函数中先调用了vsprintf函数将需要输出的字符串格式化并存入buf,同时通过其返回值得到字符串长度,然后调用write函数输出。
2、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,用格式字符串对个数变化的参数进行格式化,产生格式化输出并赋值给buf,最后返回输出字符串的长度。
3、write函数的实现
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
write函数将栈中参数放入寄存器,ecx存放字符个数,ebx存放字符串首地址,最后调用sys_call。
sys_call将字符串中的字节从寄存器中通过总线复制到显卡的显存中并以ASCII形式存储,字符显示驱动子程序根据ASCII到字模库中找到点阵信息(每一个点的RGB颜色信息)并存储到vram中。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最终将内容显示在屏幕上。
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
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;
}
异步异常-键盘中断的处理:当用户键入信息时,键盘接口会得到一个对应于该按键的按键扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先通过键盘接口得到按键扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中。
getchar函数调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码,当读到回车符时再返回。
本章主要介绍了Linux的I/O设备管理方法、Unix IO接口及其函数,并具体分析了printf函数和getchar函数的实现,让读者对这两个函数的运行原理有了更深入地了解。
一、Hello的一生
1、hello.c源文件经过预处理,得到hello.i文本文件。
2、hello.i经过编译,得到hello.s汇编文件,其中为hello程序的汇编代码实现。
3、hello.s经过汇编,得到二进制可重定位目标文件hello.o。
4、hello.o经过链接,最终生成了可执行文件hello。
5、在shell键入运行hello程序的命令,hello程序正式开始运行。
6、shell调用fork函数,为hello生成子进程。
7、shell调用execve函数,execve调用启动加载器,加载映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数。
8、在执行hello中的sleep函数时,进程会陷入内核模式,内核会处理休眠请求并进行上下文切换,将前台进程控制权交给其他进程。当定时器再次发送中断信号时,内核又会切换回hello进程。
9、在执行hello中的printf函数时,还会调用malloc函数进行动态内存分配。
10、在运行hello程序过程中,若我们用户键入ctrl+c、ctrl+z等指令,内核会发送相应信号给进程并对进程进行相应的处理。
11、最终当hello进程执行完毕,父进程会回收子进程,删除该进程创建的所有数据结构,hello的一生最终结束。
二、感悟
计算机系统的设计确实非常精妙和全面,例如存储器的多层次结构设计很好地权衡了存储容量和运行速度之间的关系,多级页表管理大大提高了空间利用率,以及动态内存分配的实现等等。
我们要成为优秀的程序员、工程师,单单关注顶层的实现是远远不够的,对于底层的原理我们也必须清楚。学习计算机系统让我对计算机有了更加深入的理解,同时,也让我感受到了前人设计计算机时的智慧,对我计算思维的培养有很大的帮助。
1、中间产物
文件名 |
文件作用 |
hello.i |
hello.c预处理后的文本文件 |
hello.s |
hello.i编译后的汇编文件 |
hello.o |
hello.s汇编后的可重定位目标文件 |
hello |
hello.o链接后的可执行目标文件 |
elf.txt |
hello.o的ELF格式 |
Helloelf.txt |
hello的ELF格式 |
Obj_hello |
hello.o的反汇编代码 |
Obj_helloo |
hello的反汇编代码 |
为完成本次大作业你翻阅的书籍与网站等
[1] 《深入理解计算机系统》 Randal E.Bryant David R.O’Hallaron 机械工业出版社
[2] 博客园 [转] printf函数实现的深度剖析https://www.cnblogs.com/pianist/p/3315801.html
[3] 博客园 linux2.6 内存管理——逻辑地址转换为线性地址(逻辑地址、线性地址、物理地址、虚拟地址)https://www.cnblogs.com/diaohaiwei/p/5094959.html
[4] 博客园 linux第三次实践:ELF文件格式分析 https://www.cnblogs.com/cdcode/p/5551649.html