本文主要介绍了hello程序在Linux系统中的生命周期。我们将结合《深入理解计算机系统》中的内容,通过研究分析hello.c经过预处理、编译、汇编、链接生成可执行文件的过程,以及计算机系统对hello可执行目标程序的进程、存储以及I/O管理,让读者对计算机系统的主要结构和基本原理有一个更加清晰的认识。
关键词:hello;Linux;计算机系统;处理器体系结构;存储器体系结构;进程
目 录
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 -
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.5.2 hello和hello.o的不同... - 29 -
6.2 简述壳Shell-bash的作用与处理流程... - 34 -
6.3 Hello的fork进程创建过程... - 35 -
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 -
第1章 概述
1.1 Hello简介
根据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进程,内核删除相关数据结构。
1.2 环境与工具
硬件环境: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
1.3 中间结果
文件名 |
文件作用 |
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.4 本章小结
本章主要介绍了hello的P2P和020的具体含义和过程,列出了作业中配置的软硬件环境、开发和调试工具以及中间过程中产生的一些文件。后文将对以本章作为总领进行具体详细的展开。
第2章 预处理
2.1 预处理的概念与作用
1、预处理的概念
预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,修改原始的C程序,其中 ISO C/C++要求支持的包括#if、 #ifdef、 #ifndef、 #else、 #elif、 #endif(条件编译)、 #define(宏定义)、 #include(源文件包含)、 #line(行控制)、 #error(错误指令)、 #pragma(和实现相关的杂注)以及单独的#(空指令)。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。
2、预处理的作用
(1)处理头文件包含指令:将源文件中用#include形式声明的头文件内容复制到程序中。例如hello.c中的#include<stdio.h>命令使预处理器将头文件stdio.h的内容合并到程序中。
(2)处理宏定义指令:用实际值替换#define定义的字符串。
(3)处理条件编译指令:根据条件编译指令#if、#ifdef等来决定需要编译那些代码,并删除不需要编译的代码。
(4)处理特殊符号:预编译程序可以识别一些特殊的符号,预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
(5)去除注释。
2.2在Ubuntu下预处理的命令
命令:gcc hello.c -E -o hello.
2.3 Hello的预处理结果解析
打开hello.i文件发现源程序被修改为3105行,hello.c的main程序出现在3091行之后。由于.c程序中main函数前只有“#include <stdio.h>”“#include <unistd.h> ”“#include <stdlib.h>”三条代码,所以在main之前只是对stdio.h、unistd.h、stdlib.h头文件的依次展开。以stdio.h为例:cpp通过路径"/usr/include/stdio.h"打开对应文件,并对该文件中的头文件包含、宏定义、条件编译以及特殊符号进行递归处理,然后再将所得到的内容全部加入到hello.i当中。
2.4 本章小结
本章介绍了预处理的相关概念以及其具体作用,如对头文件包含指令、宏定义指令、条件编译指令、特殊符号的处理,并结合对hello.c的预处理,通过分析hello.i文件详细地阐述了预处理的内涵。
第3章 编译
3.1 编译的概念与作用
1、编译的概念
编译就是编译器通过词法和语法分析,将代码指令转换为等价的中间代码或者汇编代码,将.i文件翻译成.s文件,它包含了一个汇编语言程序。该例中就是将文本文件hello.i翻译成文本文件hello.s。
2、编译的作用
编译程序的基本功能是把源程序(高级语言)翻译成目标程序。但是作为一个具有实际应用价值的编译系统,除了基本功能之外,还应具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人-机联系等重要功能。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
-
-
- 汇编指示
-
.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)的位置。
-
-
- 函数操作
-
调用函数时进行的操作:
- 传递控制:在运行被调用函数时,程序计数器必须设置为其代码的初始地址;在函数运行结束返回时,程序计数器必须设置为调用该函数指令的下一条指令的地址。
- 传递参数:调用者通过寄存器、栈向被调用函数传递参数,在X86-64中,传递的第1-6个参数依次存储在%rdi、%rsi、%rdx、%rcx、%r8、%r9 这六个寄存器中,其他参数通过栈传递。此外,被调用函数会向调用者返回一个参数(返回值)。
- 分配和释放内存:运行被调用函数前,需要为其分配空间,返回时,需要释放刚分配的空间。
- main函数
传递参数:int argc, char *argv[],分别存放在%rdi和%rsi中。
函数调用:被系统启动函数调用。
函数返回:将%eax设置为0返回,故返回值为0。
- printf函数
传递参数:第一次call puts时,传入字符串LC0的首地址;第二次call printf时,传入位于LC1的argv[1]和argv[2],即两个字符串,同时需要将%eax置零。
函数调用:被main函数在if和for语句中调用。
call puts:
call printf:
- exit函数
传递参数:传入立即数1。
函数调用:被main函数在if语句中调用。
汇编实现:
- atoi函数
传递参数:argv[3]
函数调用:被main函数在for语句中调用,返回值作为sleep函数的传入参数。
汇编实现:
- sleep函数
传递参数:传入函数atoi(argv[3])的返回值。
函数调用:被main函数在for语句中调用。
汇编实现:
- getchar函数
传递参数:无
函数调用:在main函数中被调用。
汇编实现:
3.4 本章小结
本章主要介绍了编译的相关概念以及其具体过程。我们通过对hello.i编译成hello.s的过程以及hello.s内容的探究,具体分析了汇编代码对各类数据、算术操作、关系操作、控制转移、数组/指针/结构操作以及函数操作的实现,通过源代码与C源代码的对比阅读帮助读者对汇编语言有了更好的理解。
第4章 汇编
4.1 汇编的概念与作用
1、汇编的概念
通过汇编器(as)将汇编程序翻译成与之等价的机器语言并得到一个可重定位目标程序的过程。
2、汇编的作用
汇编器(as)首先检查汇编程序(.s文件)语法的正确性,若正确,则将其翻译成与之等价的机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件(.o二进制文件)中。
4.2 在Ubuntu下汇编的命令
命令:gcc hello.s -c -o hello.o
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
-
-
- 命令
-
readelf -a hello.o > elf.txt
-
-
- ELF头(ELF Header)
-
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统 的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解 释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、 字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
-
-
- 节头部表(Section Headers)
-
节头部表描述了各个节的类型、地址、大小等信息。因为是可重定位目标程序,每个节都是从0开始。在文件头中得到节头部表的信息,再利用节头部表的字节偏移信息(Offset)得到各个节的初始位置以及每个节的大小,同时可以知道是否可执行、读写等信息。
-
-
- 重定位节(Relocation section)
-
重定位节是一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息。在链接时,需要通过重定位节对各个段应用的外部符号等的地址进行修改。
-
-
- 符号表(Symbol table)
-
符号表存放在程序中定义和引用的函数和全局变量的信息。其中,name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字;value是符号的地址。对于可定位目标文件来说,value是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行的地址。size是目标的大小(以字节为单位),type通常要么是数据要么是函数。bind字段表明符号是本地的还是全局的。
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o > obj_helloo
hello.o的反汇编
hello.s
分析hello.o的反汇编,并与第3章的 hello.s进行对照分析:
- 相比hello.s仅有汇编代码,hello.o的反汇编还有机器级指令,且和汇编代码一一对应。机器级指令由操作码和操作数构成,分别对应汇编语句中的操作指令以及操作数。
- 分支转移:在hello.s中,跳转指令的操作对象是.L2等段名称,但在hello.o的反汇编代码中,跳转指令后是相对偏移地址。
- 函数调用:在hello.s中,函数调用直接用call+函数名表示,但在hello.o的反汇编代码中,call后跟的是所调用的函数相对调用之的函数的起始位置的相对地址。
- hello.s中的立即数均是十进制表示,而hello.o的反汇编代码中立即数均为十六进制表示。
4.5 本章小结
本章对汇编的相关概念以及汇编得到的文件进行了探究。我们将hello.s进行汇编得到hello.o可重定位目标程序,这为接下来的链接做好了准备。同时也分析了ELF头、节头部表、重定位节以及符号表。最后,将hello.o的反汇编程序与hello.s进行对比,更加深刻地阐释了从汇编语言到机器级语言转变的过程。
第5章 链接
5.1 链接的概念与作用
1、链接的概念
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序自动执行的。
2、链接的作用
链接器在软件开发中扮演者一个关键的角色,因为它们使得分离编译成为可能。我们可以吧一个大型的应用程序分解为小的、好管理的模块,我们可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需要简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
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的格式
-
-
- 命令
-
readelf -a hello > helloelf.txt
-
-
- ELF头(ELF Header)
-
与hello.o的头文件不同,hello中Type为EXEC(Executable file) ,即hello为一个可执行目标文件,根据Number of section headers可知有25个节。
-
-
- 节头部表(Section Headers)
-
节头部表声明了各个节的类型、地址、偏移量等信息,通过这些信息,就可以定位到各个节所在的区域。链接器链接时,会将各个文件的相同段合并,并且根据合并段的大小以及偏移量重新设置各个符号的地址。
-
-
- 重定位节(Relocation section)
-
-
-
- 符号表(Symbol table)
-
5.4 hello的虚拟地址空间
使用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:指定在重定位结束之后哪些内存区域是需要设置只读。
5.5 链接的重定位过程分析
-
-
- 命令
-
objdump -d -r hello > obj_hello
-
-
- hello和hello.o的不同
-
1、增加函数
相比hello.o,hello中增加了hello.c源文件所用到的库函数(如printf、getchar、atoi、exit等)的具体实现。
2、增加节
hello中添加了节,如.init、.text、.fini,还加入一些节中定义的函数。
3、地址访问和函数调用
由于hello.o中的函数只有在链接后才能确定具体的地址,所以要在.rela.text节中为其添加重定位条目。而链接后的hello中没有重定位条目,跳转和函数调用的地址都是虚拟内存地址(绝对地址)。
-
-
- hello的重定位过程
-
1、重定位节和符号定义
链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
2、重定位节中的符号引用
链接器修改代码节和数据节中对每个符号的引用,使它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
3、重定位条目
当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。
重定位条目格式
4、重定位地址计算算法
5.6 hello的执行流程
使用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 |
5.7 Hello的动态链接分析
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就能跳转到正确的区域。
5.8 本章小结
本章主要介绍了链接的相关概念和其作用,探究了hello.o是如何通过链接成为一个可执行目标文件的,并分析了hello的ELF格式和各个节的含义,以及hello的虚拟地址空间、重定位过程、执行流程和动态链接。
第6章 hello进程管理
6.1 进程的概念与作用
1、进程的概念
进程的经典定义就是一个执行中程序的实例。
2、进程的作用
进程提供给应用程序两个关键抽象:
- 一个独立的逻辑控制流,好像我们的程序独占地使用处理器;
- 一个私有的地址空间,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
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返回。
6.3 Hello的fork进程创建过程
终端通过调用fork函数创建一个子进程,子进程得到与父进程用户级虚拟地址空间完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流中的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
当在shell中输入指令“./hello 1190201008 周凡 1 0”后,shell首先解析这段指令,然后发现这不是一个shell的内置命令,于是调用fork创建一个子进程运行之。
6.4 Hello的execve过程
execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。该函数的作用就是在当前进程的上下文中加载并运行一个新的程序。只有当出现错误时,例如找不到Hello时,execve才会返回到调用程序。
在execve加载了Hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数(int main(intargc , char **argv , char *envp);)。
对于exceve函数加载和执行程序hello的具体过程有如下几步:
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
- 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。图6.4概括了私有区域的不同映射。
- 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)。execve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
图6.4加载器是如何映射用户地址空间的区域的
6.5 Hello的进程执行
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 运行输出结果
6.6 hello的异常与信号处理
1、异常
类别 |
原因 |
异步/同步 |
返回行为 |
中断 |
来自I/O设备的信号 |
异步 |
总是返回下一条指令 |
陷阱 |
有意的异常 |
同步 |
总是返回下一条指令 |
故障 |
潜在可恢复的错误 |
同步 |
可能返回到当前指令 |
终止 |
不可恢复的错误 |
同步 |
不会返回 |
hello程序可能会出现的异常:
- 中断:由外部I/O设备的信号引起的异常。
- 陷阱:hello执行sleep函数时会出现这个异常。
- 故障:可能会发生缺页故障。
- 终止:可能会出现DRAM或者SRAM位损坏的奇偶错误。
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进程已经没有了。
6.7本章小结
本章介绍了进程的相关概念和作用以及shell的一般处理流程,并且通过结合hello实例具体详细地分析了shell如何调用fork创建新的进程,调用execve函数执行程序,进程执行中模式和上下文的切换以及对异常和信号的处理,使读者对进程和linux如何执行程序有了更加清晰的认识。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1、逻辑地址
逻辑地址是由程序产生的与段相关的偏移地址部分。在hello中表现为hello程序产生的与段相关的偏移地址部分。
2、线性地址
线性地址是逻辑地址到物理地址变换之间的中间层。hello程序的代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
3、虚拟地址
虚拟地址等价于线性地址。
4、物理地址
是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个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详细显示了一个逻辑地址是怎样转换成相应线性地址的,给定一个完整的逻辑地址[段选择符:段内偏移地址]:
- 看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
- 拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
- 把Base + offset,就是要转换的线性地址了。
图7.2.3逻辑地址的转换
7.3 Hello的线性地址到物理地址的变换-页式管理
1、含义及实现原理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。
将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
图7.3.1页表
图7.3.2使用页表的地址翻译
2、优点
- 由于它不要求作业或进程的程序段和数据在内存中连续存放,从而有效地解决了碎片问题。
- 动态页式管理提供了内存和外存统一管理的虚存实现方式,使用户可以利用的存储空间大大增加。这既提高了主存的利用率,又有利于组织多道程序执行。
3、缺点
- 要求有相应的硬件支持。例如地址变换机构,缺页中断的产生和选择淘汰页面等都要求有相应的硬件支持。这增加了机器成本。
- 增加了系统开销,例如缺页中断处理机。
- 请求调页的算法如选择不当,有可能产生抖动现象。
- 虽然消除了碎片,但每个作业或进程的最后一页内总有一部分空间得不到利用果页面较大,则这一部分的损失仍然较大。
7.4 TLB与四级页表支持下的VA到PA的变换
探究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第四级表条目格式
7.5 三级Cache支持下的物理内存访问
一级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物理内存访问
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。
7.7 hello进程execve时的内存映射
execve 函数在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用hello程序有效地替代了当前程序。 加载并运行hello需要以下几个步骤:
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新区域都是私有的、写时复制的。代码和数据区域被映射为 hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
- 映射共享区域。hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
图7.7加载器是如何映射用户地址空间的区域的
7.8 缺页故障与缺页中断处理
处理缺页是由硬件和操作系统内核协作完成,如图7.8所示。
图7.8缺页处理
处理流程:
- 处理器生成一个虚拟地址,并将它传送给MMU;
- MMU生成PTE地址,并从高速缓存/主存请求得到它;
- 高速缓存/主存向MMU返回PTE;
- PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序;
- 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘;
- 缺页处理程序页面调入新的页面,并更新内存中的PTE;
- 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命。
7.9动态存储分配管理
动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆,如图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排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章主要介绍了存储器地址空间的相关概念,比较了逻辑地址、线性地址、虚拟地址和物理地址四种地址空间概念的差别并结合hello程序讨论了不同地址之间的相互转换。此外,还阐述了三级cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m字节的序列:
B0,B1,B2……Bm-1
1、设备的模型化:文件
所有的 IO 设备(如网路、磁盘、终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。
2、设备管理:unix io接口
这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都被当做相应文件的读和写来执行。
8.2 简述Unix IO接口及其函数
-
-
- Unix IO接口
-
- 打开文件:内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。
- Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。
- 改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
- 读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k。
- 关闭文件:当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中。
-
- Unix IO函数
-
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 <unistd.h>
(3)函数原型:int close(int fd)
(4)参数
fd文件描述符
(5)函数返回值:成功返回0,错误返回-1。
3、read()函数
(1)功能描述
从文件读取数据。
(2)所需头文件:#include <unistd.h>
(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 <unistd.h>
(3)函数原型:ssize_t write(int fd, void *buf, size_t count);
(4)返回值:成功返回写入文件的字节数;错误返回-1。
5、lseek()函数
(1)功能描述
用于在指定的文件描述符中将将文件指针定位到相应位置。
(2)所需头文件:#include <unistd.h>,#include <sys/types.h>
(3)函数原型:off_t lseek(int fd, off_t offset,int whence);
(4)参数
fd//文件描述符
offset//偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)
(5)返回值:成功返回当前位移,失败返回-1。
8.3 printf的实现分析
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
8.4 getchar的实现分析
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码,当读到回车符时再返回。
8.5本章小结
本章主要介绍了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