HITcsapp大作业 程序人生
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 1190200121
班 级 1903001
学 生 王子奕
指 导 教 师 郑贵宾
计算机科学与技术学院
2021年6月
摘 要
本文以程序设计者的角度,遍历hello.c文件的一生。从c语言源码编译为可执行文件的整个流程,较深刻地讨论了计算机系统课程所授c代码编译链接的全过程,挖掘了系统对于进程管理的策略、存储器分层、系统I/O等知识。作为一篇课程总结报告再合适不过,同时也是一段奇妙的计算机系统浪漫之旅。
关键词:计算机系统;存储器;链接;进程;系统I/O;
目 录
第1章 概述............................................................................................................. - 4 -
1.1 Hello简介...................................................................................................... - 4 -
1.2 环境与工具..................................................................................................... - 4 -
1.3 中间结果......................................................................................................... - 4 -
1.4 本章小结......................................................................................................... - 4 -
第2章 预处理......................................................................................................... - 5 -
2.1 预处理的概念与作用..................................................................................... - 5 -
2.2在Ubuntu下预处理的命令.......................................................................... - 5 -
2.3 Hello的预处理结果解析.............................................................................. - 5 -
2.4 本章小结......................................................................................................... - 5 -
第3章 编译............................................................................................................. - 6 -
3.1 编译的概念与作用......................................................................................... - 6 -
3.2 在Ubuntu下编译的命令............................................................................. - 6 -
3.3 Hello的编译结果解析.................................................................................. - 6 -
3.4 本章小结......................................................................................................... - 6 -
第4章 汇编............................................................................................................. - 7 -
4.1 汇编的概念与作用......................................................................................... - 7 -
4.2 在Ubuntu下汇编的命令............................................................................. - 7 -
4.3 可重定位目标elf格式................................................................................. - 7 -
4.4 Hello.o的结果解析...................................................................................... - 7 -
4.5 本章小结......................................................................................................... - 7 -
第5章 链接............................................................................................................. - 8 -
5.1 链接的概念与作用......................................................................................... - 8 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
5.4 hello的虚拟地址空间.................................................................................. - 8 -
5.5 链接的重定位过程分析................................................................................. - 8 -
5.6 hello的执行流程.......................................................................................... - 8 -
5.7 Hello的动态链接分析.................................................................................. - 8 -
5.8 本章小结......................................................................................................... - 9 -
第6章 hello进程管理................................................................................... - 10 -
6.1 进程的概念与作用....................................................................................... - 10 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.4 Hello的execve过程................................................................................. - 10 -
6.5 Hello的进程执行........................................................................................ - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
6.7本章小结....................................................................................................... - 10 -
第7章 hello的存储管理................................................................................ - 11 -
7.1 hello的存储器地址空间............................................................................ - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理....................................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 11 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 11 -
7.6 hello进程fork时的内存映射.................................................................. - 11 -
7.7 hello进程execve时的内存映射.............................................................. - 11 -
7.8 缺页故障与缺页中断处理........................................................................... - 11 -
7.9动态存储分配管理....................................................................................... - 11 -
7.10本章小结..................................................................................................... - 12 -
第8章 hello的IO管理................................................................................. - 13 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
8.3 printf的实现分析........................................................................................ - 13 -
8.4 getchar的实现分析.................................................................................... - 13 -
8.5本章小结....................................................................................................... - 13 -
结论......................................................................................................................... - 14 -
附件......................................................................................................................... - 15 -
参考文献................................................................................................................. - 16 -
第1章 概述
1.1 Hello简介
- P2P - From Program to Process
P2P即“从程序到进程”。在这里程序所指的是一组指示计算机或其他具有消息处理能力设备每一步动作的指令,通常用某种程序设计语言编写,运行于某种目标体系结构上。而进程所指的是计算机中被执行的程序实例,由程序代码及其运行状态所构成。
对于程序员而言,程序最初是利用高级语言撰写的代码,例如C语言,python,java等。而在计算机内部运行的是汇编语言和机器指令。需要经过编译和链接转换成计算机能识别的可执行目标文件才可。本文利用hello.c为例阐述写好的代码在x86-64架构的Linux系统中的漫游过程。
一个系统上可以有多个进程同时运行,而每个进程好像在独立地占用内存等硬件资源。通俗来讲程序是体现在纸上的计划,进程像实施这个计划的每一个步骤。
在Linux系统环境下,C语言程序需要先经过预处理、编译、汇编和链接等四个阶段进行编译获得可执行文件。再利用shell命令将可执行文件加载到内存和系统中,从而实现“从程序到进程”的转变。
- 020 - From Zero-0 to Zero-0
020即一个进程从开始执行到终止。
下面简单介绍进程的执行顺序。首先系统shell利用fork函数新建一个子进程,再用execve函数加载目标程序,利用该程序的进程取代当前进程。在这之前,在ELF中程序头部表的引导下,加载器将可执行文件的片对应地复制到代码段和数据段,同时对栈空间和堆空间进行初始化,其余段也依次修改。紧接着,CPU对新进程执行逻辑控制流,系统为进程映射虚拟内存,跳转到入口_start函数。_start函数调用定义在libc.o中的系统启动函数__libc_start_mai。初始化执行环境,运行main函数,并在及程序结束后把控制返回给内核。最后父进程将该子进程回收,消除垃圾和浪费,一切就如同什么都未发生过一样。
1.2 环境与工具
1.2.1 硬件环境
处理器 Intel(R) Core(TM) i5-7300HQ CPU @ 2.50GHz 2.50 GHz机带 RAM 8.00 GB (7.89 GB 可用)系统类型
1.2.2 软件环境
Windows:Windows10 64位 + Visual Studio 2019 64位
Linux: Vmware虚拟机; Ubuntu 18.04 LTS 64位/优麒麟 64位;
1.2.3 开发工具
CodeBlocks;vi/vim/gpedit+gcc dev c++
1.3 中间结果
文件名称 |
文件作用 |
hello.c |
hello的C语言源代码 |
hello.i |
hello.c预处理生成的代码 |
hello.s |
hello.i编译生成的汇编代码 |
hello.o |
hello.s汇编生成的可重定位目标文件 |
Hello |
hello.o链接生成的可执行文件 |
1.4 本章小结
通过介绍P2P和O2O介绍了进程和程序、shell控制进程运行的全过程,介绍了本次大作业的环境配置和开发工具情况,一一介绍了全文所提及的文件和详细定义。
第2章 预处理
2.1 预处理的概念与作用
预处理即C语言代码在编译之前所做的准备工作。
图一 流程图
主要有四种预处理指令
1 宏 #define N (1 << 10) 全代码的N指的是(1<<10)
2 文件包含指令 #include <stdio.h> 将头文件的库加载链接到目标文件中
3 条件编译指令 #ifndef ONLINE_JUDGE #endif 利用判断语句选择宏定义
4 其他指令 #undef N 取消宏定义,指定启动或结束函数等
经过预处理生成以.i为后缀名文件。
2.2在Ubuntu下预处理的命令
利用命令gcc –E hello.c –o hello.i
图二 ubuntu下的预处理
2.3 Hello的预处理结果解析
图三 Hello预处理结果
hello.i的末尾出现hello.c源代码,前面三千多行是hello.c的三个头文件。可以得到结论hello.i仍是一个C语言程序。
2.4 本章小结
本章主要阐述了预处理的概念和作用,明确了各种预处理指令的处理方式,给出了在Linux环境下预处理C程序的命令,发现生成的.i文件仍然是c语言。
第3章 编译
3.1 编译的概念与作用
编译指通过编译器将经过预处理之后的程序转换成特定汇编代码的过程。
编译的作用在于,它能逐行检查源代码(高级语言)是否符合语法规范,并将其转换为机器中的汇编语言,汇编程序中的每条语句都以某种格式标准确切地描述了一条低级机器语言指令。此处特殊说明不同架构的系统平台下产生的代码可能不同,例如windows和linux系统的区别
3.2 在Ubuntu下编译的命令
利用指令 gcc -S hello.i -o hello.s
得到可执行目标汇编文件hello.s
图一 ubuntu下的处理
3.3 Hello的编译结果解析
3.3.1常量
整型常量则被当作操作数,直接存储在.text节中,如下图所示中的立即数
图二 立即数存放方法
两处字符串常量储存在.rodata节,下图所示。
图三 存储字符串常量
3.3.2变量
对于局部变量,程序储存在寄存器或用户栈中,函数返回时恢复栈帧。例如,在hello.s中,循环变量i存放在-4(%rbp)中(如下图所示)。
图四 循环变量i的存储
C程序的全局变量,已初始化的存放在.data节,未初始化的或初始化为0的存放在.bss节,本次作业中hello没有全局变量。
3.3.3数组
Main函数的参数中,argv是char*型的数组。argv作为hello的第二个参数,寄存器%rdi中存放其首地址,栈空间中存放在-32(%rbp)位置。引用数组元素时,利用“基地址加偏移量”的方式进行寻址查找数组元素,如下图所示。
图五 数组的查找方式
3.3.4赋值
赋值操作一般用mov指令实现。
图六 mov 操作
此外已初始化全局变量的初始值直接保存在.data段内,无需进行mov指令。
3.3.5算数操作
指令 |
效果 |
描述 |
|
INC |
D |
D←D + l |
加1 |
DEC |
D |
D←D - l |
减1 |
NEG |
D |
D←-D |
取负 |
NOT |
D |
D←~D |
取补 |
ADD |
S, D |
D←D + S |
加 |
SUB |
S, D |
D←D - S |
减 |
IMUL |
S, D |
D←D * S |
乘 |
XOR |
S, D |
D←D ^ S |
异或 |
OR |
S, D |
D←D | S |
或 |
AND |
S, D |
D←D & S |
与 |
SAL |
k, D |
D←D << k |
左移 |
SHL |
k, D |
D←D << k |
左移(等同于SAL) |
SAR |
k, D |
D←D <<A k |
算术右移 |
SHR |
k, D |
D←D <<L k |
逻辑右移 |
lea |
S, D |
D←&S |
加载有效地址 |
hello.s中只有循环i加1进行了算数运算,此外无其他类型运算操作,如下图所示
3.3.6关系操作和控制转移
汇编指令中,cmp和test指令用于关系判断。
指令 |
基于 |
描述 |
|
cmpb |
Si, S2 |
S2 - Si |
比较字节 |
cmpw |
Si, S2 |
比较字 |
|
cmpl |
Si, S2 |
比较双字 |
|
cmpq |
Si, S2 |
比较四字 |
|
testb |
Si, S2 |
S2 & Si |
测试字节 |
testw |
Si, S2 |
测试字 |
|
testl |
Si, S2 |
测试双字 |
|
testq |
Si, S2 |
测试四字 |
在hello文件中循环的结束条件利用了判断语句
cmp和test指令只对条件码(OF、ZF、CF等)进行设置,不改变寄存器的值。
指令 |
同义名 |
跳转条件 |
描述 |
|
jmp |
Label |
|
1 |
直接跳转 |
jmp |
*Operand |
|
1 |
间接跳转 |
je |
Label |
jz |
ZF |
相等/零 |
jne |
Label |
jnz |
~ZF |
不相等/非零 |
js |
Label |
|
SF |
负数 |
jns |
Label |
|
*SF |
非负数 |
jg |
Label |
jnle |
~ (SF ^ OF) & ~ZF |
大于(有符号> ) |
jge |
Label |
jnl |
- (SF ^ OF) |
大于或等于(有符号>=) |
jl |
Label |
jnge |
SF ^ OF |
小于(有符号< ) |
jle |
Label |
jng |
(SF ^ OF) | ZF |
小于或等于(有符号<=) |
ja |
Label |
jnbe |
~CF & ~ZF |
超过(无符号> ) |
jae |
Label |
jnb |
~CF |
超过或相等(无符号>=) |
jb |
Label |
jnae |
CF |
低于(无符号< ) |
jbe |
Label |
jna |
CF | ZF |
低于或相等(无符号<=) |
从表中可以看出,除jmp直接跳转指令以外的其他指令都是有条件的,它们根据条件码的某种组合,跳转到目标位置。此外jmp跳转一般是PC相对的,也就是说,汇编码汇编为机器字节码时,它们会将目标指令的地址与当前的PC值之间的差作为编码。
在hello中程序用cmpl指令比较了常数7和循环变量i的大小关系作为判断循环结束的标志,若不满足结束条件,则跳转到.L4标号处继续执行循环体,否则结束循环。
3.3.7参数构造与过程调用
本作业过程调用主要指函数调用。在hello.c中调用了printf、exit、sleep、getchar四个函数。
过程P调用过程Q,需遵循如下步骤:
(1) 传递控制。开始时,PC必须被设置为 Q 的起始地址;返回时,PC设置为 P 中调用 Q 的下一条指令的地址。
(2) 传递数据。P能够向 Q传递一个或者多个参数,Q 必须能够向 P 返回一个值。
(3) 分配和释放内存。在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放掉这些空间,这些空间往往是运行时栈利用push leave等指令。
Hello文件情况如下图所示
开始:
反回
图七 具体操作
Leave释放栈帧空间
3.4 本章小结
本章阐述了编译的概念与作用,演示了Linux下将C语言源代码转化为汇编代码的操作。通过给出常见的表并结合hello实例进行说明。
对于hello的编译结果针对不同数据类型,操作指令进行说明。
第4章 汇编
4.1 汇编的概念与作用
概念:将汇编程序.s文件转化为二进制字节码文件(后缀名.o)的过程叫做汇编,它把汇编指令打包成一种叫做可重定位目标程序的格式。
作用:通过这个过程,将汇编代码转化为机器可以理解的二进制字节码, 形成可执行可链接格式文件(即ELF文件),使之在链接后能够被机器执行。
4.2 在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -S hello.s -o hello.o
或
as hello.s -o hello.o
图一 ubuntu下的命令
4.3 可重定位目标elf格式
利用readelf -a hello.o 查看elf信息
下图为典型的ELF文件中的各类信息
图二 ELF文件
左侧是在可链接文件,右侧是在可执行文件
4.3.1 ELF头
图三 ELF头
ELF头的信息如图4-3所示。可以看到,它以一个16字节的序列表示该文件得以运行的系统的字的大小和字节顺序。之后shi1ELF头的大小、目标文件的类型、机器类型、字节头部表、如厚点、程序头起点以及节头部表中条目数量等信息,这些信息可以帮助链接器进行语法分析和解释目标文件等。
4.3.2节头部表
节头部表,用于描述目标文件的节,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。
图四 节头部表
4.3.3重定位节
.rela.text:一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时对这些位置进行修改。.rela.eh_frame包含的是eh_frame的重定位信息,储存了一些调试信息。
图五 重定位节信息
4.4 Hello.o的结果解析
4.4.1机器字节码构成
机器语言的字节码指令也有严格的标准和格式,字节级编码利用几个字节表示到各种汇编语言指令。
以Y86-64架构为例。字节级指令的第一个字节表明了指令的类型。这个字节分为两部分,高4位是代码部分,低4位是功能部分。有的指令还有第二个字节,它代表指令操作的目标寄存器,每个寄存器(包括“无寄存器”,即空参数)都有唯一对应的寄存器标识符。少数指令在第一个或第二个字节后还会带有一个8字节操作数,代表目标地址或立即数。
图六 Y86指令
4.4.2反汇编码与汇编码的对比
图七 反汇编得到的代码
图八 由编译器生成的代码
二者在指令内容上基本一致,但也存在一些区别:
(1) 反汇编码左侧部分是十六进制地址即相对于程序头的字节偏移量。这是汇编器汇编后利用二进制机器指令的字节数确定的。
(2) 反汇编码中的负数表示是由补码转换而来,因为负数转换为补码的过程发生在汇编阶段
(3) 反汇编代码call指令是用PC相对地址寻址的,而在汇编码中中则是用函数名(符号)。这是因为hello.c源代码中调用的函数是共享库中的函数(如printf、getchar等),在动态链接器链接之前无法确定函数运行时的实际地址。因此,对于这些地址不确定的函数调用,在编译时要用符号占位,汇编时则要使用相对地址(偏移)。即PC加偏移量为函数地址的计算方式。
(4) 反汇编代码中,分支转移控制使用PC相对地址寻址的,而在汇编码中则是使用符号。如(1)中所述,汇编之前无从得知目标指令的地址,因此,在汇编码中只能用符号代替。只有在在汇编之后,各指令的相对地址都已确定的情况下才换用相对地址。
(5) 在汇编代码中,访问全局变量时,使用段名称+%rip,在反汇编代码中则是X+%rip(X为重定位条目地址)。这是因为.rodata段的地址也是在运行时方能确定,所以对.rodata中数据的访问也需要重定位,而这项工作也是在汇编阶段完成的。
4.5 本章小结
本章介绍了汇编。利用汇编器(as)将汇编代码hello.s转化为可重定位目标文件hello.o,得到一个可以用于链接的二进制文件。通过readelf我们可以查看hello.o的elf信息和重定位信息并介绍了ELF中的段头表,节头表,ELF头等信息。我们对比hello.o的反汇编和hello.s,从而对汇编过程有更深入的理解和思考。
第5章 链接
5.1 链接的概念与作用
链接的结果生成可执行文件,将相关的目标文件连接起来,使得这些目标文件成为操作系统可以装载执行的统一整体的过程。
链接分为静态链接和动态链接模式:
(1) 静态链接。静态链接时,外部函数的代码将从其所在的静态链接库中拷贝到最终的可执行程序中。这样,程序执行时,这些代码就会被装入到对应进程的虚拟内存空间里。这里的静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码。
(2) 动态链接。动态链接中,外部函数的代码被放到动态链接库或共享对象的某个目标文件中(通常以.so为后缀名)。链接器在链接时所做的只是在生成的可执行文件中记下共享对象的名字等少量信息。在可执行文件运行行时,动态链接库的全部内容将被映射到相应进程的虚拟内存空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码。
5.2 在Ubuntu下链接的命令
ld -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/usr/lib/gcc/x86_64-linux-gnu/7/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello指令
生成可执行目标文件hello
图一 ubuntu下运行
5.3 可执行目标文件hello的格式
利用readelf -a hello.o 查看elf信息
下图为典型的ELF文件中的各类信息
图二 ELF文件
左侧是在可链接文件,右侧是在可执行文件
图三 ELF头信息
ELF头中的信息包含各节的名称、大小、属性和相对偏移量等
下图为节头部表
节头部表对hello中所有的节信息进行了声明,包括大小size以及在程序中的偏移量offset等信息,并可以根据此定位各个节所占的区间。
图四 节头部表
下图为程序头部表
图五 程序头部表
下图为段映射和动态节项目信息
图六 段映射和动态节项目信息
下图为重定位条目信息
图七 重定位条目信息
下图为符号表
图八 符号表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,hello的虚拟地址从0x0000000000400000。代码段都是从0x400000开始,最多到2^48-1.
图九 ELF头
(1) PHDR:起始位置为0x400040,大小为0x1c0。
图十 PHDR
(2) INTERP:起始位置为0x400200,大小为0x1c。
图5-8 INTERP
(3) LOAD:代码段起始位置为0x400000,大小为0x820。见图九
内存段起始位置 0x600e00 大小为0x254
图十一 内存段
5.5 链接的重定位过程分析
5.5.1 对比分析
图十二 objdump反汇编代码
(1)上图所示划红线的部位,在hello.o中待重定位的部分(如callq指令的操作数)全部换成了具体地址。
(2)hello.o只有代码段.text,而hello经过链接成为可执行文件,增加了.init(初始化)、.plt(过程链接表)和.fini(结束段)等段。
(3)hello.o的指令“地址”仅仅是一个偏移量,而非真正的虚拟地址。链接之后,各指令就都具有了0虚拟地址。不仅是指令有了地址,分支转移指令的操作数也由偏移量变成了地址。
5.5.2 重定位过程
链接器完成符号解析之后,将代码中的每个符号引用和一个符号定义 (即它的某个输入目标模块中的一个符号表条目)相关联起来。此时,链接器已经知道输入目标模块中代码节和数据节的确切大小,将进行重定位。
下面是重定位的过程:
(1) 节和符号定义的重定位。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的. data节被全部合并成一个节,这个节成为输出的可执行目标文件的. data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节、每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
(2) 符号引用的重定位。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中的重定位条目。
举例说明
图十三 sleep函数调用
图十四 sleep函数信息
利用前文所述方法进行验证
0x4010C0-0x4-0x401105-0x6b=-0xb4=0xffffff4c
小端法和预期值相等就得到了图5-12中的结果
5.6 hello的执行流程
使用edb执行hello,观察从加载hello到_start,到main,以及程序终止的所有过程。下面列出调用与跳转的子程序名和子程序地址。
子程序名 |
子程序地址 |
ld-2.27.so!_dl_init |
0x7f80befe4093 |
ld-2.27.so!_dl_start |
0x7f80befe40c5 |
hello!main |
0x4005e7 |
hello!puts@plt |
0x4004b0 |
hello!exit@plt |
0x4004e0 |
hello!printf@plt |
0x4004c0 |
hello!sleep@plt |
0x4004f0 |
hello!getchar@plt |
0x4004d0 |
图十五 edb查看信息
图十六 function
子程序名 |
备注 |
ld-2.27.so!_dl_start |
动态链接库初始化 |
ld-2.27.so!_dl_init |
|
hello!_start |
hello程序入口点 |
libc-2.27.so!__libc _start_main |
系统启动函数 初始化执行环境 调用main 处理main的返回值 |
libc-2.27.so!_exit |
终止进程(动态链接器) |
hello!_libc_csu_init |
|
hello!main |
hello主函数 |
hello!puts@plt |
hello调用 |
hello!exit@plt |
hello调用 |
hello!printf@plt |
hello调用 |
hello!atoi@plt |
hello调用 |
hello!sleep@plt |
hello调用 |
hello!getchar@plt |
hello调用 |
libc-2.27.so!_exit |
终止进程(hello) |
5.7 Hello的动态链接分析
函数调用一个由共享库定义的函数时,编译器无法预先判断出函数的地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定的方式解决该问题,在运行时动态载入。延迟绑定通过两个数据结构之间简洁但又有些复杂的交互来实现,即过程链接表(PLT)和全局偏移量表(GOT)。
过程链接表(PLT):PLT是一个数组,其中每个条目是16字节代码。PLT [0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。每个条目都负责调用一个具体的函数。
全局偏移量表(GOT):GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT [0]和GOT [1]包含动态链接器在解析函数地址时会使用的信息。GOT [2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。_ld_start 前:
图十七 start前
_ld_start 后:
图十八 start后
对应的内存存储了动态链接器在解析函数地址时会使用的信息。
5.8 本章小结
本章介绍了链接的概念及作用,分析了hello的ELF格式,深入学习了hello.o 可重定位文件到hello可执行文件的流程,和链接的各个过程介绍了链接器如何将hello.o可重定向文件与动态库函数链接起来。
第6章 hello进程管理
6.1 进程的概念与作用
进程就是“一个执行中的程序的实例”。
系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈空间、通用目的寄存器、程序计数器PC、环境变量以及打开文件描述符的集合等。进程给应用程序提供了关键的抽象,独立的逻辑控制流,和“每个程序都独占处理器和系统内存”的假象。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个用C语言编写的程序,它是用户使用Linux的桥梁。因此,Shell 既是一种命令语言,又是一种程序设计语言,具有其他普通语言的很多特点(如也有分支结构、循环结构等),功能十分强大。Shell允许用户编写和运行由Shell命令组成的程序。
Shell包含sh、csh、bash、zsh等许多种。其功能在于解释命令,内容为用户输入的命令并且把它们送到内核。
Shell的处理流程为首先检查该命令是否为内部命令,若不是则检查是否其是一个应用程序。如果命令指定了程序的路径,Shell就按路径定位该程序;反之,Shell会在环境变量给出的搜索路径里寻找程序。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,系统就会显示错误信息。如果能够成功找到命令,该内部命令或应用程序则将被分解为系统调用并传送给Linux内核。
6.3 Hello的fork进程创建过程
父进程可以通过fork函数创建一个新子进程。函数原型为pid_t fork(void);
fork函数返回值分两种情形,父进程内返回值为子进程的PID,子进程内返回值围为0。PID为每个进程独有的进程号。
新创建的子进程与父进程几乎完全相同。子进程得到与父进程用户级虚拟地址空间相同(但是独立的)一份副本,包括代码段和数据段、堆、共享库以及用户栈。子进程还会获得父进程所打开的文件描述符的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件,若子进程需要对内存修改,才会产生新的物理内存副本,否则二者的虚拟内存映射到相同物理内存。父进程和新创建的子进程之间最大的差别在于它们有不同的 PID。
fork函数有以下几种特性。
(1) 调用一次,返回两次。父进程调用一次fork,有一次是返回到父进程,而另一次是返回到子进程的,返回值的区别在前文已经叙述。
(2) 并发执行。父进程和子进程是并发运行的独立进程,内核可以以任意方式交替执行它们的逻辑控制流中的指令。我们不能对不同进程中指令的交替执行做任何假设。
(3) 相同但独立的地址空间。两个进程有相同的用户栈、运行时堆和本地变量值等,但它们对各自内存空间的修改是相互独立的。有项技术叫作写时复制。事实上,在物理内存中,一开始,两个进程指向的地址确实是相同的;但是,一旦一方对部分共享空间做了修改,这部分空间就会被拷贝出去,不再共享。
(4) 共享文件。子进程会继承父进程打开的所有文件。
6.4 Hello的execve过程
main开始执行时,用户栈的组织结构如下图所示。
execve函数在当前进程的上下文中加载并运行一个新程序。内部参数有const char *filename const char *argv[] const char *envp[]
execve函数加载并运行可执行目标文件filename,带上参数列表argv和环境变量列表envp。该函数返回值,出现错误返回-1,否则不返回。
execve加载了filename之后,程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段并重新初始化栈空间和堆空间。接着,CPU为新进程分配时间片执行逻辑控制流,跳转到程序的入口点 _start函数的地址。在起点处调用定义在libc.o中的系统启动函数__libc_start_main,该函数。它初始化执行环境,调用main函数,同时处理main函数的返回值。
6.5 Hello的进程执行
6.5.1用户模式和内核模式
为了使操作系统内核提供一个无懈可击的进程抽象,我们的处理器必须提供一种机制,从而能限制应用可以执行的指令以及它可以访问的地址空间范围。
处理器通常是通过某个控制寄存器中的模式位来提供这种功能的。该寄存器的信息描述了当前进程的特权。设置模式位时,进程就运行在内核模式中,有较高的权限。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程在用户模式中运行。用户模式中的进程不允许执行特权指令如停止处理器、改变模式位或者发起I/O等操作,此外系统也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。这些操作都会导致保护故障。因此,用户程序必须通过系统调用接口来间接地访问内核代码和数据。
进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将从用户模式变为内核模式。处理程序运行在内核模式中其返回到应用程序代码时,处理器就从内核模式改回到用户模式。
6.5.2逻辑控制流与上下文切换
即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序,我们会看到一系列的程序计数器PC的值,这些值唯一地对应于包含在程序的可执行B文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,简称逻辑流。
逻辑流关键点在于进程是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占,暂时挂起,然后轮到其他进程。对于单个进程来说,它看上去就像是在独占地使用处理器。然而,如果我们精确地检测最每条指令使用的时间,会发现在一些指令之间, CPU好像会周期性地停顿。不过,停顿之后,它会继续执行程序,且不改变内存位置或寄存器的内容。
CPU内核使用上下文切换的较高层形式的异常控制流来实现多任务。
具体来说,内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态,由一些对象的值组成,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表等。
上下文切换分三个步骤:
(1) 保存当前进程的上下文
(2) 恢复某个先前被抢占的进程被保存的上下文
(3) 将控制传递给这个新恢复的进程。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始执行一个先前被抢占了的进程。这种决策就被称为调度,是由内核中的调度器处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个 新的进程运行后,它就抢占当前进程,并使用上下文切换来将控制转移到新的进程。
中断也可能引发上下文切换。若在发生中断时判断当前进程已经运行了足够长的时间,则切换到一个新的进程。
6.6 hello的异常与信号处理
hello执行时,可能产生四种类别的异常:
类别 |
原因 |
同步/异步 |
返回行为 |
中断 |
来自I/O设备的信号 |
异步 |
总是返回到下一条指令 |
陷阱 |
有意的异常 |
同步 |
总是返回到下一条指令 |
故障 |
潜在可恢复的错误 |
同步 |
可能返回到当前指令 |
终止 |
不可恢复的错误 |
同步 |
不会返回 |
hello执行时,还可以发送或接收信号。信号是一种系统消息,它用于通知进程系统中发生了某种类型的事件,是一种更高层的软件形式的异常。不同的事件对应不同的信号类型。信号传送到目的进程由发送和接收两个步骤组成。信号的发送者一般是内核,接收者是进程。
发送信号可以有如下两种原因:
(1) 内核检测到一个系统事件(如除零错误或者子进程终止);
(2) 一个进程调用了kill函数,显式地要求内核发送一个信号给目的进程。
接收信号是内核强迫目的进程做出的反应。进程可以以默认方式做出反应,也可以通过信号处理程序捕获这个信号。每个信号只会被处理一次。
待处理信号指的是已经发送而没有接收的信号。任何时候,一种信号类型至多有一个待处理信号,即信号不会排队。
进程可以有选择性地阻塞接收某种信号。被阻塞的信号仍可以发出,但不会被目标进程接收。
信号种类繁多,现列举如表6‑2所示。
编号 |
信号名称 |
默认行为 |
说明 |
1 |
SIGHUP |
终止 |
终止控制终端或进程 |
2 |
SIGINT |
终止 |
键盘产生的中断 |
3 |
SIGQUIT |
dump |
键盘产生的退出 |
4 |
SIGILL |
dump |
非法指令 |
5 |
SIGTRAP |
dump |
debug中断 |
6 |
SIGABRT/SIGIOT |
dump |
异常中止 |
7 |
SIGBUS/SIGEMT |
dump |
总线异常/EMT指令 |
8 |
SIGFPE |
dump |
浮点运算溢出 |
9 |
SIGKILL |
终止 |
强制进程终止 |
10 |
SIGUSR1 |
终止 |
用户信号,进程可自定义用途 |
11 |
SIGSEGV |
dump |
非法内存地址引用 |
12 |
SIGUSR2 |
终止 |
用户信号,进程可自定义用途 |
13 |
SIGPIPE |
终止 |
向某个没有读取的管道中写入数据 |
14 |
SIGALRM |
终止 |
时钟中断(闹钟) |
15 |
SIGTERM |
终止 |
进程终止 |
16 |
SIGSTKFLT |
终止 |
协处理器栈错误 |
17 |
SIGCHLD |
忽略 |
子进程退出或中断 |
18 |
SIGCONT |
继续 |
如进程停止状态则开始运行 |
19 |
SIGSTOP |
停止 |
停止进程运行 |
20 |
SIGSTP |
停止 |
键盘产生的停止 |
21 |
SIGTTIN |
停止 |
后台进程请求输入 |
22 |
SIGTTOU |
停止 |
后台进程请求输出 |
23 |
SIGURG |
忽略 |
socket发生紧急情况 |
24 |
SIGXCPU |
dump |
CPU时间限制被打破 |
25 |
SIGXFSZ |
dump |
文件大小限制被打破 |
26 |
SIGVTALRM |
终止 |
虚拟定时时钟 |
27 |
SIGPROF |
终止 |
剖析定时器期满 |
28 |
SIGWINCH |
忽略 |
窗口尺寸调整 |
29 |
SIGIO/SIGPOLL |
终止 |
I/O可用 |
30 |
SIGPWR |
终止 |
电源异常 |
31 |
SIGSYS/SYSUNUSED |
dump |
系统调用异常 |
(1)乱摁
图一
(2)Ctrl+C
图二
(3)Ctrl+Z
图三
(4)Ps
图四
(5)Jobs
图五
(6)Pstree
图六
(7)Fg
图七
(8)Kill
图八
6.7本章小结
先前的章节一直在研究C语言代码的编译和可执行文件的生成过程。从这一章起,开始讨论程序的运行。为了描述程序运行,进程的概念不得不提,它是计算机科学中最深刻、最成功的概念之一。进程为程序提供的抽象环境,使得进程可以同时地、并发地执行。
为了高效地描述系统中发生的各类事件,则需要用到信号,这是一种更高层级的软件形式的异常。利用信号,内核和进程之间得以高效地传递信息并对各类事件做出相应的反应。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:在计算机体系结构中逻辑地址是指应用程序角度看到的内存单元、存储单元、网络主机的地址,即hello.o里面的相对偏移地址。逻辑地址往往不同于物理地址,通过地址翻译器或映射函数可以把逻辑地址转化为物理地址。
线性地址:线性地址是逻辑地址到物理地址变换之间的中间层,即hello中的虚拟地址,等于逻辑地址加上基地址。逻辑地址可转化为线性地址,其地址空间是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间。
虚拟地址:虚拟地址是程序用于访问物理内存的逻辑地址,即线性地址,在hello中为虚拟地址。
物理地址:计算机的主存被组织成一个由M个连续的字节大小的单元组成的数组。每个字节都有一个唯一的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.2.1. 基本原理
在段式存储管理中,将程序的地址空间分为若干段,这样每个程序都有一个二维的地址空间。在段式存储管理系统的,为每个段分配一个连续的分割槽,而程序中的各个段可以分配在内存的不同区域。程序载入时,操作系统为所有段分配所需的内存,这些段不需要连续。这些内存通过动态分割槽的管理方法。总而言之,段式储存管理造成没有内部碎片,外部碎片可以通过内存压缩来消除;便于实现内存共享。缺点与页式储存管理的缺点相同,程序必须全部装入内存。
7.2.2. 段式管理的数据结构
为了实现段式管理,需要如下的数据结构来实现程序的地址空间到物理内存空间的对映,并跟踪物理内存的使用情况,以便在装入新的段的时候,合理地分配内存空间。为了完成上述的功能,—个段式系统中,一般要采用如下的数据结构:
(1) 程序段表:描述组成程序地址空间的各段,可以是指向系统段表中表项的索引。每段有段基址,即段内地址。
(2) 系统段表:系统所有占用段(已经分配的段)。
(3) 空闲段表:记忆体中所有空闲段,可以结合到系统段表中。
7.2.3. 段式管理的地址变化
在段式管理系统中,整个程序的地址空间是二维的,即其逻辑地址由段号和段内地址两部分组成。为了完成程序逻辑地址到实体地址的对映,处理器会查询内存中的段表,由段号得到段的首地址,加上段内地址,得到实际的实体地址。这个过程也是由处理器的硬件直接完成的,系统只需在程序切换时,将程序段表的首地址装入处理器的特定寄存器(即段寄存器)当中。
7.3 Hello的线性地址到物理地址的变换-页式管理
7.3.1. 基本原理
将程式的逻辑地址空间划分为定大小的页(page),而物理内存小的页帧(page frame)。程式载入时,可将任意一页放入内存中任意一个页帧,这些页帧不必连续,从而实现了离散分配。该方法需要CPU的硬件支持,来实现逻辑地址和实体地址之间的对映。在页式储存管理方式中地址结构由两部构成,前一部分是虚拟页号(VPN),后一部分为虚拟页偏移量(VPO):
页式管理方式的优点是:没有外部碎片;一个程序不必连续存放;便于改变程序占用空间的大小(主要指随着程序执行,动态生成的数据增多,所要求的地址空间相应增长)。其缺点是:要求程序全部装入内存,没有足够的内存,程序就不能执行。
7.3.2. 页式管理的数据结构
在页式系统中程序建立时,系统为程序中所有的页分配页帧。当程序撤销时收回所有分配给它的页帧。在程序的执行期间,如果允许程序动态地申请空间,系统还要为程序申请的空间分配物理页帧。系统为了完成这些功能,必须记录系统内存中实际的页帧使用情况。系统还要在程序切换时,需要正确地切换两个不同的程序地址空间到物理内存空间的对映。这就要求系统要记录每个程序页表的相关信息。为了完成上述的功能,—个页式系统中,一般要采用如下的数据结构:
页表:页表将虚拟内存对映到物理页。每次内存管理单元将一个虚拟地址转换为物理地址时,都会读取页表。页表是一个页表条目(PTE)的数组。虚拟地址空间的每个页在页表中一个固定偏移量处都有一个PTE。假设每个PTE是由一个有效位和一个n位地址栏位组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设定了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设定有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。
7.3.3. 页式管理的地址变化
MMU利用VPN来选择适当的PTE,将列表条目中PPN和虚拟地址中的VPO串联起来,就得到相应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1提供到一个Ll PET的偏移量,这个PTE包含L2页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
7.5.1存储器金字塔
为了平衡成本和效能,计算机系统逐渐演化出了如图7‑13所示的存储器金字塔。越往塔尖,速度越快,但成本也越高,容量也越小。金字塔的第二、三、四层是一个相对统一的整体,分别对应于L1、L2、L3三级高速缓存(cache)。
7.5.2 高速缓存读写策略
1.首先是写回法和写直达法(全写法),这是在cache命中时所采用的两种方法:其中写回法指的是若写cache则仅修改cache’中的内容直到cache被换出时才修改主存中的内容;而写直达法是修改cache时同时修改主存。
2.其次是写分配法和非写分配法,这是在cache未命中时所采用的两种方法:其中写分配法是把这个未在cache中的块加载到cache中后在进行写;而非写分配法指的是直接把这个块在主存中写不进行调块。
3.为什么写回法搭配写分配法?(cache与主存之间采用)
若搭配非写分配法,写未命中时会直接修改主存中的内容,若一直有新的写入会一直修改主存。
若搭配写分配法,写未命中会调入cache并修改,之后的新的写会直接修改cache,直到被替换后再写入主存中。
相比较而言选择写分配法。
4.为什么全写法搭配非写分配法?(各级cache之间采用)
若搭配写分配法,写未命中会调入cache并修改,之后的新的写入会同时修改cache和主存。
若搭配非写分配法,写未命中时会直接修改主存中的内容,若一直有新的写入会一直修改主存。
相比较而言选择非写分配法。
7.5.3利用物理地址访问高速缓存
高速缓存的结构可以用元组(S, E, B, m)来描述(如图7‑14a所示)。髙速缓存的大小(或容量)C指的是所有块的大小的和。标记位和有效位不包括在内。因此,C = S×E×B。
当一条加载指令指示CPU从主存地址A中读一个字时,它将地址A发送到高速缓存。如果高速缓存正保存着地址A处那个字的副本,它就立即将那个字发回给CPU。那么高速缓存如何知道它是否包含地址A处那个字的副本的呢?高速缓存的结构使得它能通过简单地检查地址位,找到所请求的字,类似于使用极其简单的哈希函数的哈希表。
图7‑14 高速缓存与物理地址
参数S和B将M个物理地址位分为了三个字段:标记、组索引和块偏移,如图7‑14b所示。A中的s个组索引位是到S个组的数组的索引。第一个组是组0,第二个组是组1,依此类推。组索引位是一个无符号整数,它告诉我们这个字存储在哪个组中。一旦我们知道了这个字存放在哪个组中,A中的z个标记位就告诉我们这个组中的哪一行包含这个字(如果有的话)。当且仅当设置了有效位并且该行的标记位与地址A中的标记位相匹配时(类似与哈希表查找时,哈希值的匹配),组中的这一行才包含这个字。一旦我们在由组索引标识的组中定位了由标号所标识的行,那么就可以由6个块偏移位确定所需的字在该行数据块中的字偏移量。
Intel Core i7总的地址翻译流程如图7‑15所示。
7.6 hello进程fork时的内存映射
当fork函数被shell调用时,内核为hello创建各种数据结构,并分配一个唯一的PID。为了给hello创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新的页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve 函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello内存会有如下便变化:
(1) 删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
(2) 映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。下图概括了私有区域的不同映射。
图7-4 私有区域的内存映射
(3) 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。例如:CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。缺页之前:
图7-5 缺页前的虚拟内存和物理内存
接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬体。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。缺页之后:
缺页处理程序随后就执行下面的步骤:
(1) 虚拟地址A是合法的吗?换句话说,A在某个区域结构(区域就是已分配的虚拟内存的连续片)定义的区域内吗?缺页处理程序会搜索区域结构的链表,把A和每个区域结构中的vm_start和 vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。
(2) 试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程
(3) 若不满足以上两种情形,内核明白这个缺页是由于对合法的虚拟地址进行合法的操作造成的。接下来,内核会遵循前文所述方法进行处理,这里不再重复
7.9动态存储分配管理
printf函数会调用malloc,下面简述动态内存管理的基本方法与策略:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器分为两种基本风格:
(1) 显式分配器,要求应用显式地释放任何已分配的块。
(2) 隐式分配器,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾回收器,而自动释放未使用的已经分配的块的过程叫做垃圾收集。
对于C程序,使用的是显式分配器,能够显式地释放任何已分配的块。C标准库通过malloc程序包的显示分配器。
图7-7 malloc函数
malloc函数返回一个指针,指向大小至少为size字节的内存块,这个块可能包含在这个快内的任何数据对象类型做对齐。
程序是通过free函数来释放已分配的堆块。
图7-8 free函数
1.1.
1.2.
1.3.
1.4.
1.5.
1.6.
1.6.1. 隐式空闲链表
我们可以将堆组织为一个连续的已分配块和空闲块的序列,空闲块是通过头部中的大小字段隐含地连接着的,这种结构为隐式空闲表。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块地集合。
一个块是由一个字的头部、有效载荷、可能的填充和一个字的脚部,其中脚部就是头部的一个副本。头部编码了这个块的大小以及这个块是已分配还是空闲的。分配器就可以通过检查它的头部和脚部,判断前后块的起始位置和状态。
图7-9 隐式空闲链表的一个块
1.6.2. 显式空闲链表
将堆组成一个双向空闲链表,在每个空闲块中,都包含一个pred和succ指针。
一种方法是用后进先出(LIFO)的顺序来维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用。
一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小快大小,也潜在的提高了内部碎片的程度。
7.10本章小结
在这一章中,主要介绍了程序的存储结构,介绍了段式管理和页式管理两种从虚拟地址到物理地址的管理方式。程序访问过程中的cache结构和页表结构,进程如何加载自己的虚拟内存空间,内存映射和动态内存分配。
虚拟内存可以为每个进程分配一个独立的虚拟内存空间而不不会受到其他进程影响,多级缓存可以提高数据访问的速度,动态内存分配器则可以提高内存的利用率和效率。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux下的文件就是一个m个字节的序列。
所有的I/O装置(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应档案的读和写来执行。这种将装置映射为文件的方式,允许Linux核心引出一个简单、低阶的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
所有的输入和输出都能以一种统一且一致的方式来执行:
(1) 打开文件。一个应用程序要求通过内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个标识符。
(2) Linux shell创建的每个进程开始时都有三个打开的文件:标准输入、标准输出和标准错误。
(3) 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开始的字节偏移量。应用程序能够通过执行seek操作,现显式地设置文件的位置为k。
(4) 读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file的条件,应用程序能够检测到这个条件。在文件结尾处并没有明确的“EOF符号”。
(5) 关闭文件。当应用程序完成了对文件的访问后,它就通知内核需要关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
Unix I/O函数原型及功能如下:
(1) Open():进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的。open将filename转换为一个文件描述符,并且放回描述符数字。
(2) Close():进程通过调用close函数关闭一个打开的文件。关闭一个已关闭的描述符会出错。
(3) Read():应用程序通过read函数来执行输入。read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误;返回值0表示EOF;否则,返回值表示的是实际传扫的字节数量。
(4) Write():应用程序通过write函数来执行输出。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
printf函数:
图一 printf 函数
Va_list是一个字符指针,arg赋值为...中的第一个参数。
Vsprintf函数:
其的作用就是格式化使它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,从而能产生格式化输出。注意此处仅实现了对16进制的格式化。
然后追踪write函数汇编代码
图三 write 函数汇编代码
int INT_VECTOR_SYS_CALLA代表通过系统调用sys_call。
图四 syscall 的实现
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中储存的是字节的ASCII码。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求。键盘中断服务程序先从键盘接口取得按键的扫描码,然后根据扫描码判断用户所按的键并作出相应的处理
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法,简要分析了Unix IO接口及其函数,对于printf函数的代码和getchar函数的实现进行进一步的理解。
结论
Hello一生主要经过了两个过程P2P和020。
(1)首先我们要在编辑器上对hello.c进行编写
(2)然后是P2P的第一个阶段预处理:预处理器cpp读取系统头文件内容,将hello引用的外部库导入,得到hello.i。
(3)编译:编译器ccl将文本文件hello.i汇编成hello.s。
(4)链接:将hello.o与和外部库链接为可执行程序hello。
(5)020第一个阶段fork:shell调用fork函数为hello产生子进程。
(6)Execve:Shell调用execve函数加载运行当前进程的上下文中加载并运行新程序hello。
(7)执行:内核为hello维持一个上下文,系统为它提供独占处理器和空间内存的假象
(8)异常:hello执行过程中可能会受到很多信号比如ctrl+c,ctrl+v等等影响hello的执行。
(9)回收: hello终止后由父进程回收,不留痕迹。
首先感谢郑贵滨老师这学期的谆谆教导,也要感谢课程组老师的精心准备和助教老师的工作。计算机系统这门课是从计算机硬件到软件各个层面的摄入分析,各种要点细致入微,在课堂上通过各种例子由浅入深的剖析现代计算机各种先进结构和处理方法。本门课程的学习后我对计算机的组成原理,使用有了大致的了解。计算机性能的影响要素是方方面面的,经过历史不断地摸索沉淀才形成了我们现在使用的计算机。同时这门课程有趣的实验和作业更打开了我对计算机这个领域的一个新的认知,从硬件的研发优化到通过算法在应用层面合理的使用硬件等等,里边包含着太多的学问,我认为在我未来的路上还要继续上下求索,细细品味。
附件
文件名 |
文件作用 |
hello.i |
预处理器生成的文件 |
hello.s |
hello.i编译后达到的文件 |
hello.objdump |
hello.s的反汇编文件 |
hello.o |
hello.s汇编后得到的可重定位目标文件 |
hello |
hello.o和外部文件进行连接形成的可执行文件 |
对于hello和hello.o 的读elf头信息和反汇编都在terminal中完成,没有生成对应文件。
参考文献
[1] 兰德尔E.布莱恩特. 大卫R.奥哈拉伦.等 深入理解计算机系统[M]. 北京:机械工业出版社.2019.
[2] 逻辑地址到线性地址的转换. https://blog.csdn.net/weixin_30457465/article/details/98170346
[3] 线性地址到物理地址的转换. https://blog.csdn.net/qq_40890756/article/details/90255947
[4] Pianistx. printf 函数实现的深入剖析. https://www.cnblogs.com/pianist/p/3315801.html