摘要
对hello程序的整个生命周期进行了详细阐述。通过对hello.c的编写,预处理,编译,汇编,链接生成可执行文件与可执行文件执行所在进程的创建,程序执行和进程结束后的回收过程了详细的阐述,使读者能够了解一个C语言程序从出生到死亡和最后资源回归系统的全过程。
关键词:预处理,编译,汇编,链接,进程管理,存储管理,IO管理
概述
1.1 Hello简介
(1)P2P:hello.c经过预处理(ccp)变为hello.i,再经过编译器(ccl)编译,生成汇编程序hello.s,再经过汇编器(as)生成可重定位目标程序hello.o,之后通过连接器(ld)生成可执行目标程序hello。在shell中,启动hello后,shell为其fork一个进程并加载可执行文件进行执行。
(2)020:shell通过execve函数调用加载器为子进程加载可执行文件hello,加载器将可执行文件的数据和代从磁盘复制到内存中,然后跳转到程序的第一条指令或入口点来执行该程序,CPU要为hello的运行分配时间片来执行进程的逻辑控制流。程序结束后,shell父进程回收终止的hello进程,内核删除它的相关数据结构。
1.2 环境与工具
(1)硬件环境
X64 CPU;2GHz;2G RAM;256GHD Disk 以上。
(2)软件环境
Windows7/10 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位 以上。
(3)软件
Visual Studio 2010 64位以上;CodeBlocks 64位;vi/vim/gedit+gcc。
1.3 中间结果
过程如下图,hello.c经处理生成的中间文件为hello.i,hello.s,hello.o,最终生成可执行文件hello。
1,hello.i,通过预处理生成的预处理文件(文本)。可以处理条件编译指令,头文件指令,特殊符号等。
2,hello.s,通过编译生成的汇编文件(文本)。以汇编语言或机器语言表示程序的目的。
3,hello.o,通过汇编生成的可重定位目标文件(二进制)。包含二进制代码和数据,其形式可以在链接时与其他可重定位文件合并起来形成可执行目标文件。
1.4 本章小结
简单的介绍了hello的P2P,O2O过程及hello.c 源程序到hello可执行目标文件的过程及中间过程产生的文件及其作用。
预处理
2.1 预处理的概念与作用
(1)概念
所谓预处理是指在进行编译第一遍(词法扫描和语法分析)之前的工作。“#”开头的是预处理指令。预处理是C语言的重要功能,由预处理程序负责完成。预处理在源代码编译之前对其进行一些文本性质的操作。
(2)作用
处理宏定义指令,预处处理器可以根据预处理指令将源程序中的某些部分包含进文件或者排除在外转换为空行。
处理条件编译指令,预处理可以引入使得程序员可以通过定义不同的宏对哪些代码进行处理。
处理头文件,预处理可以将头文件中的定义添加到产生的输出文件中去
处理特殊符号,预处理可以处理特殊符号,并且用合适的值进行替换。
删除注释,预处理可以删除程序的注释。
2.2在Ubuntu下预处理的命令
(1)预处理命令 gcc -E hello.c -o hello.i
效果截图如下
2.3 Hello的预处理结果解析
源程序文件hello.c如下
预处理文件hello.i如下
hello.i文件仍为C语言程序,其将预处理指令进行了处理展开,代码数量明显增加,源程序在最后面,已经去除了原始程序的相应头文件。
由于头文件内容的插入导致了代码数量的增加,同时插入内容也保证了程序对头文件的需求的以满足
2.4 本章小结
介绍了预处理的概念和功能,并且hello.c到hello.i的变化展示了程序预处理的结果。
编译
3.1 编译的概念与作用
(1)概念
通过词法分析,语法分析,语义分析,将程序转换为指令集的过程。
(2)作用
生成程序相应的汇编代码,代表程序的行为,并进行相关优化,是将源程序转化为执行文件的核心步骤
3.2 在Ubuntu下编译的命令
(1)命令:gcc -S hello.i -o hello.s
截图如下
3.3 Hello的编译结果解析
产生的hello.s结果如下图
1,
.file 声明源文件
.text 代码节
.section .rodata 只读数据段
.align 数据或指令地址对齐方式
.string 声明字符串(LC0,LC1)
.global 全局变量
.type 声明符号类型
2,
首先进行栈操作,入栈%rdp的值,并将%rsp(栈指针)的值存入%rbp进行保存,之后将栈指针减去32,寄存器中的数值直接使用寄存器作为操作单位,后缀q 代表8字节操作,
mov将前一个数值赋值给后一个
sub将第二个参数的数值减去第一个参数赋给第二个参数
3,
将寄存器%edi(操作低4位,同时高四位设置为0)(通常存储第一个输入参数) ,%rsi的数值分别存入%rbp-20与%rbp-32处,操作内存中的数值需要间接引用,偏移(基址,操作数,比例因子),后缀l代表操作低四位
4,
比较+条件跳转,将%rbp-20处的数值与四比较等于跳转到.L2 ,条件跳转的条件来自于最近的一步对符号位的操作
5,4中不满足跳转条件时
将.LC0的的字符串的首地址赋值给%rdi作为参数传入puts(call 用来调用函数)输出.LC0处字符串,即用法: Hello 学号 姓名 秒数!,将%edi赋值为1,退出
lea取地址指令,取第一个参数的地址赋给第二个参数
6,4中满足跳转条件时
先将%rbp-4位置处栈中的数值赋值为0
直接跳转到.L3(jmp代表直接跳转)
7,
先比较%rbp-4处存储的数值和7比较(常数直接在前面加上$来引用)
jle,小于等于
小于等于7则跳转到L3
否则等待读取一个字符
让后将%eax的数值赋值为0,操作后四位前四位变为0
返回(以%eax的数值作为返回值)
ret包含pop和jmp两种作用,可以弹出栈内的值并跳转到相应位置
8,若7跳转
将%rbp-32处的数值赋值给寄存器%rax
然后将%rax上的数值加16
将%rax处内存中的值赋值给%rdx
将%rbp-32处的数值,即shell中姓名的首地址赋值给寄存器%rax
然后将%rax上的数值加8
将%rax处内存中的值,即学号的首地址赋值给%rax
将%rax中的数值赋值给%rsi
在将.LC1的首地址赋值给%rdi
将%eax赋值为0
%rdi %rsi %rdx依次作为输入的三个参数
调用printf()函数进行打印
将%rbp-32处的数值赋值给寄存器%rax
然后将%rax上的数值加24
将%rax处的内存的数值赋值给%rax
再将%rax中的数值赋值给%rdi,作为参数
调用atoi函数
将函数返回数值赋值给%rdi作为传入参数
调用sleep函数
将%rbp-4处的内存数值加1
重复7
注:add指令将后一个存储的数值变为该数值加上前一个数值的结果
3.4 本章小结
介绍了编译的概念与原理,并通过hello.c的汇编代码hello.s进行分析展示了汇编代码怎样处理源程序的数据与逻辑指令。
汇编
4.1 汇编的概念与作用
(1)概念,汇编器(as)将hello.s翻译为机器语言,产生可重定向目标程序的过程
(2)作用,将汇编代码转换为机器指令,使其在链接后能够被机器识别并执行
4.2 在Ubuntu下汇编的命令
命令:gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
典型的hello.o ELF结构如下图,.debug需要以-g选项调用编译器驱动程序才能得到
(1)命令:readelf -h hello.o elf头
ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件头ELF的系统的字的大小和字节顺序。ELF头剩下的部分包含帮 助链接 器语法 分析和解释目标文件的信息。其中包括ELF头的大小 、目标文件的类型(如 可重定位、可执行或者共享的), 机器类型(如 X86-64),节头部表(section header table) 的文件偏移,以及节头部表中条目的大小和数量。
(2)命令 :readelf -S hello.o 节头部表
不同节的位置和大小等信息是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry) 。
(3)命令 readelf -s hello.o 符号表
它存放在序中定义和引用的函数和全局变量的信息 。每个可定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
由模块 m 定义并能 被其他 模块引用的全 局符号 。全局 链接器符号对 应于非静态的C函数和全局变量。
由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对于在其他模块中定义的非静态C函数和全局变量。
只被模块定义和引用的局部符号。 它们对应于带 static属性的C函数和 全局变量。这些符在模块m中任何位置都可见,但是不能被其他模块引用
type 表示符号类型
bind 表明符号是本地的还是全局的
size 表示大小
(4)命令: readelf -r hello.o 重定位节
.rel.text :—个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的 指令都需要修改。另一方面,调用本地函数的指令则不需要修改。此外,可执行目标文件中并不需要重定位信息,因此通常省略。
R_X86_64_PC32重定位一 个使用 32 位 PC 相 对地址 的引用。一个 PC 相对地址就是距程序计数器(PC) 的当前运行时值的偏移量。当CPU执行一 条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址(如 call指令的目标),PC 值通常是下一条指 令在内存中的地址。
R_X86_64_32 重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接用在指令中编码的 32 位值作为有效地址,不需要进一步修改。
offset是需要被修改的引用的节偏移。symDol标识被修改引用应该指向的号。 type 告知链接器如何修改新的引用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
4.4 Hello.o的结果解析
(1)操作数,数字在反汇编时变成16进制数,而在hello.s中为十进制数,机器指令中数据和指令均以16进制表示
(2)函数调用,对于函数调用不再是需要重定位的标记而是程序内具体的位置
(3)分支转移,不再使用标志,而是使用程序内部确切的地址
二者的对可见下面的两张图
objdump 反汇编结果如下
hello.s如下
4.5 本章小结、
介绍了可重定位目标文件的结构,和其内部elf头,头部表,符号表,重定位节的内容,并且通过对比hello.s 与hello.o的反汇编展示了hello.s生成hello.o后内容的差异。
链接
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的格式
(1)指令:readelf -h hello elf头与hello.0不同
(2)指令 readelf -S hello 节头部表
保存了不同节位置和大小等信息
效果如下
(3)指令:readelf -s hello 符号表
有三种不同的符号:
由模块 m 定义并能 被其他 模块引用的全 局符号 。全局 链接器符号对 应于非静态的C函数和全局变量。
由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对于在其他模块中定义的非静态C函数和全局变量。
只被模块定义和引用的局部符号。 它们对应于带 static属性的C函数和 全局变量。这些符在模块m中任何位置都可见,但是不能被其他模块引用
(4) 指令 readelf -r hello
重定位节
.rel.text :—个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的 指令都需要修改。另一方面,调用本地函数的指令则不需要修改。此外,可执行目标文件中并不需要重定位信息,因此通常省略。
R_X86_64_PC32重定位一 个使用 32 位 PC 相 对地址 的引用。一个 PC 相对地址就是距程序计数器(PC) 的当前运行时值的偏移量。当CPU执行一 条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址(如 call指令的目标),PC 值通常是下一条指 令在内存中的地址。
R_X86_64_32 重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接用在指令中编码的 32 位值作为有效地址,不需要进一步修改。
offset是需要被修改的引用的节偏移。symDol标识被修改引用应该指向的号。 type 告知链接器如何修改新的引用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
效果如下
5.4 hello的虚拟地址空间
虚拟地址空间起始于0x400000
对照节头部表可知各个节的起始地址
5.5 链接的重定位过程分析
指令 objdump -d -r hello
hello与hello.o的不同,与hello.o的反汇编文件对比发现,hello的反汇编中多了许多节。前者中只有一个.text节,而且只有一个main函数,函数地址也是默认的0x000000。后者中有.init,.plt,.text三个节,而且每个节中有很多函数。库函数的代码都已经链接到了程序中。
重定位过程:
重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。
重定位条目当编译器遇到对最终位置未知的目标引用时,它会生成一个重定位条目。代码的重定位条目放在.rel.txt中。
5.6 hello的执行流程
401000 <_init>
401020 <.plt>
401030 puts@plt
401040 printf@plt
401050 getchar@plt
401060 atoi@plt
401070 exit@plt
401080 sleep@plt
401090 <_start>
4010c0 <_dl_relocate_static_pie>
4010c1
401150 <__libc_csu_init>
4011b0 <__libc_csu_fini>
4011b4 <_fini>
5.7 Hello的动态链接分析
变换后
5.8 本章小结
介绍了可执行目标文件的结构和与重定向目标文件的差异,和可执行目标文件的执行过程的一些变化
进程管理
6.1 进程的概念与作用
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情 况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数 据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动 过程调用的指令和本地变量。
作用:进程为用户提供了以下假象:
(1) 我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存。
(2) 处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理
(1)Shell 是一种命令行解释器, 其读取用户输入的字符串命令, 解释并且执行命令. 它是一种特殊的应用程序, 介于系统调用/库与应用程序之间, 其提供了运行其他程序的的接口.它可以是交互式的, 即读取用户输入的字符串;也可以是非交互式的, 即读取脚本文件并解释执行, 直至文件结束. 无论是在类 UNIX, Linux 系统, 还是 Windows, 有很多不同种类的 Shell: 如类 UNIX, Linux 系统上的 Bash, Zsh 等; Windows 系统上的 cmd, PowerShell 等.
(2)Shell的处理流程大致如下:
从Shell终端读入输入的命令
切分输入字符串,获得并识别所有的参数
若输入参数为内置命令,则立即执行
若输入参数并非内置命令,则调用相应的程序为其分配子进程并运行
若输入参数非法,则返回错误信息
处理完当前参数后继续处理下一参数,直到处理完毕
6.3 Hello的fork进程创建过程
根据shell的处理流程,可以推断,输入命令执行hello后,父进程如果判断不是内部指令,即会通过fork函数创建子进程。子进程获取了与父进程的上下文,包括栈、通用寄存器、程序计数器,环境变量和打开的文件相同的一份副本。子进程与父进程的最大区别是有着跟父进程不一样的PID。Fork函数只会被调用一次,但会返回两次,在父进程中,fork返回子进程的PID,在子进程中,fork返回0。子进程可以读取父进程打开的任何文件。当子进程运行结束时,父进程如果仍然存在,则执行对子进程的回收,否则就由init进程回收子进程。
6.4 Hello的execve过程
在 execve 加载了 filename 之后,它调用 7. 9 节中描 述的启 动代码 。启 动代 码设置栈 ,并将 控制传递给新 程序的 主函数 , 该主函 数有如 下形式的原型
int main (int argc, char ** argv, char ** envp) ;
或者 等价的
int main(int argc, char *argv [] , char *envp[]);
当 main 开始 执行时 ,用户栈的 组织结 构如图 8-22 所示。 让我们 从栈底(高 地址)往栈顶(低地址)依次 看一看。首先是参数 和环境 字符串。栈 往上紧随其后的是以 null 结尾 的指针数组,其中每 个指针 都指向 找中的 一 个环 境变量 字符串。全 局变量 environ 指向 这些指针中的第一个 envp[]紧随环境 变量数组之后的是以 null 结尾的 argv[] 数组,其中每个兀素 都指向 钱中的 一 个参数字符串。在 找的 顶部是 系统启 动函数 libc_start_main
6.5 Hello的进程执行
在进程执行的某些时刻,内核可以决定抢占当前进程 ,并重新开始一个先前被 抢占了的进程 。这种决策就叫做调度(scheduling), 是 由内核中称为调度器(scheduler) 的代码处理的 。当内核选择 一个新 的进程运行时, 我们说内核调度了这个进程 。在内核调度 了一个新的进程运行后,它就抢占当前进程,并使用一种称为 上下文 切换的 机制来将控制转移到新的进程 ,上下 文切换 1) 保存 当前进 程的上下文, 2) 恢 复某个先前被 抢占的 进程被 保存的上下文, 3) 将控制 传递给 这个新恢复的进程。当内 核代表 用户执行系统调用时,可能会发生上下文切换 。如果 系统调用因为 等待某个事件发生而阻塞 ,那么内核可以让当前进程休眠,切换到另一个进程 。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是sleep系统调,它显式地请求让调用进程休眠。一般而言,使系统调用没有阻塞 ,内核也可以决定执 行上文切换 ,而不是将控制返回给调用进程。
6.6 hello的异常与信号处理
(1)异常信号
(3)异常类别 中断,陷阱,故障,终止,及处理方式
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
正常运行
最终输入回车结束
按回车
多处的回车会多打印空行,并在缓冲区等待接收
乱按
内容会显示到屏幕上,然后等待接受输入内容
按ctrl+c
程序收到SIGINT信号 shell 结束并回收hello
按ctrl+z
程序收到SUGSTP信号,shell提示信息并挂起hello
ctrl+z后
按ps 查看进程
按jobs
查看当前工作 hello编号为1 状态停止
按pstree将进程树状显示
按fg
hello转到前台继续执行
按kill
向进程传递信号 指令kill -9 %1 可以杀死进程
6.7本章小结
介绍了进程的概念和创建,与进程中加载可执行程序的方式。展示了信号的种类和处理方式。展示了hello运行中输入内容对进程的影响,将程序转移到前台执行的命令,查看进程的命令与向进程传递信号的命令。
内存管理
7.1 hello的存储器地址空间
(1)逻辑地址
由程序产生的与段相关的偏移部分
(2)线性地址
地址空间(address space) 是一个非负整数地址的有序集合,若地址空间中的整数是连续的我们称其为线性地址空间。
(3)虚拟地址
虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的和私有的地址空间。通过一个很清晰的机
制,虚拟内存提供了三个重要的能力:1)它将主存看成是一个存储在磁盘上的地址 空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存。2)它为每个进程提供了一致的地址空间,从而简化了内存管理。3) 它保护了每个进程的地址空间不被其他进程破坏。
(4)物理地址
物理地址是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么hello的线性地址会使用页目录和页表中的项变换成hello的物理地址;如果没有启用分页机制,那么hello的线性地址就直接成为物理地址了。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段是对程序逻辑意义上的一种划分,一组完整逻辑意义的程序被划分为一段。段的长度不确定。段描述符用于描述一个段的详细信息。段选择符用于找到对应的段描述符。
流程:
通过段选择符的T1字段,确定是GDT中段还是LDT中的段。
查找段描述符,获得基地址
基地址+偏移,得到线性地址
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU 中的一个控制寄存器,页表
基址寄存器(Page Table Base Register PTBR)指向当前页表。 72 位的虚拟地址包含两个部分:
一个 p 位的虚拟页 面偏移( Virtual Page Offset, VPO)和一个(n — f)位的虚 拟页号( Virtual Page Number, VPN) MMU 利用 VPN 来选择适当的 PTE 例如, VPNO 选择 PTEO,VPN1 选择 PTE1ÿ 以此类推。将页表条目中物理页号(Physical Page Number, PPN)和虚拟地址中的 VPO 串联起来,就得到相应的物理地址
7.4 TLB与四级页表支持下的VA到PA的变换
VPN 被划分成四个片,每个片被用作到一个页表的偏移量。 CR3 寄存器包含 L1
页表的物理地址。 VPN 1 提供到一个 LI PET 的偏移量,这个 PTE 包含 L2 页表的基地
址。 VPN2 提供到一个 L2PTE 的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
得到物理地址PA后,我们将物理地址进行分割。物理地址(52位)被分割为40位的标记位CT,6位的索引位CI,6位的块偏移CO。通过CT查找告诉缓存中的对应块,通过CI在块中寻找行,若命中,则返回对应块偏移的数据。否则,L1不命中,我们需要前往L2,L3甚至是主存中得到对应的数据
7.6 hello进程fork时的内存映射
当 fork 函数被当 前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID 为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct,区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当 fork 在新进程中返回时, 新进程现在的虚拟内存刚好和调用 fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念
7.7 hello进程execve时的内存映射
,函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用 hello程序有效地替代了当前程序。加载并运行hello 需要以下几个步骤:
删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
映射私有区域。为新程序的代码、 数据、 bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 a.out 文件中的.text和.data 区。 bss 区域是请求二进制零的,映射到匿名文件, 其大小包含在hello中。栈和堆区域也是请求二进制零的, 初始长度为零。图 9-31 概括了私有区域的不同映射。
映射共享区域。如果hello程序与共享对象(或目标) 链接,比如标准 C 库 libc.那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC)。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的人口点。
下一次调度这个进程时,它将从这个入口点开始执行。 Linux 将根据需要换入代码和数据页面
7.8 缺页故障与缺页中断处理
MMU 在试图翻译某个虚拟地址 A 时,触发了一个缺页。 这个异常导致控制转
移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
虚拟地址 A 是合法的吗? 换句话说, A 在某个区域结构定义的区域内吗? 为了回答这个问题,缺页处理程序搜索区域结构的链表,把 A 和每个区域结构中的 vm_start 和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。
试图进行的内存访问是否合法? 换句话说,进程是否有读、 写或者执行这个区域内页面的权限? 例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的? 这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的? 如果试图进行的访问是不合法的, 那么缺页处理程序会触发一个保护异常,从而终止这个进程。
此刻, 内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的: 选择一个牺牲页面, 如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时, CPU 重新启动引起缺页的指令,这条指令将再次发送 A 到 MMU这次, MMU 就能正常地翻译 A 而不会再产生缺页中断了
7.9动态存储分配管理
动态内存分配器维护者一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种基本风格。两种风格都是要求显示的释放分配块。
显式分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。
隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器。
隐式空闲链表
这种情况下,一个块是由一个字的头部、有效载荷,以及可能的填充组成。头部编码了这个块的大小以及这个块是已分配的还是空闲的。块的头最后一位指明这个块是已分配的还是空闲的。
头部后面是应用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小任意。空闲块通过头部块的大小字段隐含的连接着,所以我们称这种结构为隐式空闲链表。
分离的空闲列表
一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关
系的时间来分配块。一种 流行的 减少分配时间的方法,通常称为分 离存储(segregated storage), 就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类, 也叫做大小类(size class)ÿ 有很多种方式来定义
大小类。例如,我们可以根据 2 的幂来划分块大小:
{1},{2},{3,4},{5 〜 8},…,{1025 〜 2048},{2049〜 4096},{4097〜 ∞}
或者我们可以将小的块分派到它们自己的大小类里, 而将大块按照 2 的幂分类:
{1},{2},{3},…,{1023},{1024},{1025 〜 2048},{2049 〜 4096},{4097 ~ 〇〇}
分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。
当分配器需要一个大小为n的块时,它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表,以此类推。
7.10本章小结
介绍了线性地址,虚拟地址等概念和段页式管理方法,分析了fork,execve的处理方式和动态的内存分配与管理。
IO管理
8.1 Linux的IO设备管理方法
每个 Linux 文件都有一个类型(type)来表明它在系统中的角色:
•普通文件(regular file)包含任意数据。应用程序常常要区分文本文件(text file)和二进制文件(binary file), 文本文件是只含有 ASCII 或 Unicode 字符的普通文件; 二进制文件是所有其他的文件。对内核而言, 文本文件和二进制文件没有区别。Linux 文本文件包含了4个文本行(text line)序列,其中每一行都是一个字符序列,以一个新行符(“ \ n”)结束。新行符与 ASCII 的换行符(LF)是一样的,其数字值为 0x0a
•目录(directory)是包含一组链接(link)的文件,其中每个链接都 将一个 文件名(filename)映射到一个文件, 这个文件可能是另一个目录。每个目录至少含有两个条目: 是到该目录自身的链接,以及是到目录层次结构中父目录的链接。可以用 mkdir 命令创建一个目录,用 ls 查看其内容用 rmdir 删除该目录。
•套接字(socket)是用来与另一个进程进行跨网络通信的文件.其他文件类型包含命名通道(named pipe)符号链接(symbolic link), 以及字符和块设备(character and block device)。
Linux 内核将所有文件都组织成一个目录层次结构(directory hierarchy), 由名为/(斜杠) 的根目 录确定。系统中的每个文件都是根目录的直接或间接的后代。
8.2 简述Unix IO接口及其函数
(1)打开文件:Open函数 int open(char *filename, int flags, node_t mode)
将 filename 转换为一个文件描述符, 并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。 flags 参数指明了进程打算如何访问这个文件:
O_RD0NLY: 只读。
O_WR0NLY: 只写。
O_RDWR: 可读可写。
flags 参数也可以是一个或者更多位掩码的或,为写提供给一些额外的指示:
O_CREAT: 如果文件不存在,就创建它的一个截断的(truncated)(空) 文件。
O_TRUNC: 如果文件已经存在,就截断它。
O_APPEND: 在每次写操作前,设置文件位置到文件的结尾处。
mode 参数指定了新文件的访问权限位。
(2)关闭文件:close函数 int close(int fd) //fd文件描述符
关闭一个已经关闭的描述符会出错
(3)读文件 ssize_t read(int fd , void void *buf, size_t n)
read 函数从描述符为 fd 的当前文件位置复制最多 n 个字节到内存位置 bufÿ 返回值一1,表示一个错误,而返回值 〇 表示 EOF 否则,返回值表示的是实际传送的字节数量。
(4)写文件 ssize_t write(int fd, const void *buf, size_t n )
write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件位置。
(5)lseek函数可以修改当前文件位置
8.3 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;
}
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
介绍了unixIO设备的管理方法与相关函数,简单的解释了printf函数与getchar函数的基本原理
结论
流程
初始时,hello.c是存储在磁盘上的一个文本文件。
hello.c经过cpp进行了预处理,形成了另一个文本文件hello.i
hello.i经过编译器cc1翻译成文本文件hello.s,hello.s中包含着汇编语言代码。
hello.s经过汇编器as翻译成机器语言指令,并打包形成可重定位目标文件。
链接器ld将hello.o需要的各种目标文件与hello.o进行链接,形成可执行目标文件hello
使用shell执行hello
shell使用fork创建子进程,使用execve加载并运行hello程序。这一过程中,涉及到了虚拟内存,内存映射等知识。
hello运行过程中,可能要到各种异常,收到各种信号,hello可能需要陷入到内核,调用异常处理程序。
hello调用printf,使用UNIX IO来进行输出
hello运行结束,被父进程回收
这门课让我深入了解了计算机系统的硬件实现和软件实现,了解了计算机上程序的从创建到回收的一系列流程,让我感受到计算机技术的魅力。