题 目 程序人生-Hello’s P2P
专 业 计算机
学 号 120L021716
班 级 2003005
学 生 蔡泽栋
指 导 教 师 吴锐
计算机科学与技术学院
摘 要
本文介绍了hello.c文件编写完成后在Linux下运行的完整生命历程,对预处理、编译、汇编、链接、进程管理、存储管理、I/O管理这些hello程序的生命历程进行详细、清楚地解释。通过运用一些工具清晰地观察hello程序完整的周期,直观地表现了程序从开始到结束的生命历程。
关键词:预处理;编译;汇编;链接;进程;存储;I/O
目录
第1章 概述
1.1 Hello简介
1.2 环境与工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1 预处理的概念与作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.4 Hello.o的结果解析
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.6 hello的执行流程
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.6 hello的异常与信号处理
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
7.10本章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
附件
参考文献
P2P: From program to process,即从程序到进程。在Linux中,hello.c经过cpp预处理、ccl编译、as汇编、ld链接最终成为可执行目标程序hello,在shell中键入启动命令后,shell为其fork产生一个子进程,然后hello便从程序变为了进程。
020: shell为此子进程execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。
1.2.1 硬件环境
i7-X64 CPU;2.30GHz;2G RAM;256GHD Disk
1.2.2 软件环境
Windows 10 64 位 VitualBox 15.1.0 Ubuntu 1 8 .04 LTS
1.2.3 开发工具
Visual Studio 2022 64位;CodeBlocks 64位;vi/vim/gedit+gcc
文件名称 |
说明 |
hello.c |
hello源文件 |
hello.i |
预处理后文件 |
hello.s |
编译得到的汇编文件 |
hello.o |
汇编后的可重定位目标文件 |
hello |
链接后可执行文件 |
hello.elf |
hello的elf文件 |
本章主要介绍了hello的P2P,020过程,给出了本次实验的环境。也列出了为编写本论文生成的中间结果文件并解释了其作用。
预处理的概念:预处理是计算机对一个程序处理的第一步,对.c文件初步处理成一个.i文件。预处理器根据以字符#开头的命令,修改原始的C程序:是指在进行编译的第一遍扫描之前所做的工作。当对一个c源文件进行预处理时,系统自动引用预处理程序以解析以字符#开头的预处理命令,比如#include
预处理的作用:
删除宏定义“#define”展开并解析所定义的宏,处理所有条件预编译指令。插入include后面的文件到“#include”处。删除所有的注释。最后将处理过后的新的文本保存在hello.i中。
命令 gcc -E hello.c -o hello.i
结果如下图所示:
hello.i文件的内容增加到3000多行,预处理器对源文件中的宏进行了宏展开,对#define相应的符号进行了替换,同时也将系统头文件中的内容直接插入到了程序文本中。
预处理是计算机对程序进行操作的第一个步骤,预处理器会对hello.c文件进行初步的解释,对头文件、宏定义和注释进行操作,将程序中涉及到的库中的代码补充到程序中,将注释这个对于执行没有用的部分删除。
概念:编译器将文本文件hello.i翻译成hello.s,它包含一个汇编语言程序。它是以高级程序设计语言书写的源程序作为输入,以汇编语言或者机器语言表示的目标程序作为输出。
作用:进行词法分析、语法分析和目标代码的生成,检查无误后生成汇编语言。
命令 gcc -S hello.i -o hello.s
结果如下图所示:
3.3.1 汇编指令
.file:声明源文件
.text:代码节
.rodata:只读代码段
.align:数据或者指令的地址对其方式
.string:声明一个字符串(.LC0,.LC1)
.global:声明全局变量(main)
.type:声明一个符号是数据类型还是函数类型
3.3.2 数据
1. 字符串
第一个字符串“用法: Hello 学号 姓名 秒数!\n”存放在只读数据段.rodata中,被编码成utf-8格式,其中一个汉字占三个字节。
第二个字符串"Hello %s %s\n",输出传入的格式化参数,存放在只读数据段.rodata中。
2. main函数的参数argc
用户传递给main函数的参数argc被放到了堆栈。19行将栈地址保存在%rbp中,第22行%edi保存函数传入的第一个参数即argc,存放在-20(%rbp)的位置。
3. main函数的参数argv数组
argv每个元素char*大小为8字节,指针指向已分配好存放字符指针的连续空间,起始地址为argv。第23行%rsi保存函数传入的第二个参数即argv数组的首地址,存放在-32(%rbp)的位置。
访问数组元素argv[1],argv[2]和argv[3]时,按照起始地址argv大小8位计算数据地址取数据。
4. 临时变量int i
main函数内声明的局部变量i编译的时会放在堆栈中,即栈上-4(%rbp)的位置。
3.3.3 操作
1. 算术操作
for循环中临时变量i++,通过add指令实现。
2.赋值操作
for循环中对i赋初值的操作通过mov指令来进行实现。
3.关系操作
if语句判断argc!=4,设置条件码,为之后je跳转做准备。
for循环的条件判断i<8,比较i是否小于等于7,被编译为cmpl $7,-4(%rbp)。
4.数组操作
访问数组元素argv[1],argv[2]和argv[3]时,按照起始地址argv大小8位计算数据地址取数据。argv[]先被存在用户栈中,再用基址加偏移量寻址访问argv[1],argv[2]和argv[3]在第34、37和44行使用三次%rax取出其值。
5.控制转移操作
控制转移在本程序中包括if条件分支引起的跳转以及for循环分支引起的跳转。通过关系操作cmpl进行比较设置条件码,之后根据条件码进行跳转。
6.函数操作
main函数:
传递数据:外部调用过程向main函数传递参数argc和argv,分别使用%edi和%rsi存储,将%eax设置0返回。
printf函数:
传递数据:第一次printf将%rdi设为“用法: Hello 学号 姓名 秒数!\n”字符串的首地址。第二次printf将 %rdi设为“Hello %s %s\n”的首地址,设置%rdx为argv[1],%rsi为argv[2]。
控制传递:第一次printf有一个字符串参数, call puts@PLT,第二次printf使用call printf@PLT。
exit函数:
传递数据:将%edi设置为1。
控制传递:call exit@PLT。
sleep函数:
控制传递:call sleep@PLT。
atoi函数:
控制传递:call atoi@PLT。
getchar函数:
控制传递:call gethcar@PLT
本章介绍了linux环境下对C语言程序进行预处理之后的文件进行编译的命令,用hello程序实际演示对编译结果hello.s的简单分析,通过源程序与汇编语言程序的对比,说明了编译器是怎么处理C语言的各个数据类型以及各类操作的,分数据,赋值,算数操作,关系操作,数组,控制转移,函数操作等方面按照类型和操作进行了分析。
汇编指的是汇编器将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标文件,并将结果保存在目标文件hello.o中。
hello.o是一个二进制文件,包含hello程序执行的机器指令。汇编的作用是将汇编语言翻译成机器可以直接读取分析的机器指令。
命令 gcc -c hello.s -o hello.o
结果如下图所示:
1. 查看ELF头
命令:readelf -h hello.o
以16字节的序列Magic即魔数开始,描述了生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息。
2. 查看节头部表
命令:readelf -S hello.o
以16字节的序列Magic即魔数开始,描述了生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息。
2. 查看节头部表
命令:readelf -S hello.o
ELF文件中共有13个节,包含了文件中出现的节的类型、位置和大小等信息。每个节都从0开始,用于重定位。在文件头中得到节头部表的信息,再使用字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小等。
部分节的名称及内容如下:
节名称 |
包含内容 |
.text |
已编译程序的机器代码 |
.rela.text |
一个.text节中位置的列表,链接器链接其他文件时,需修改这些内容 |
.data |
已初始化的全局和静态C变量 |
.bss |
未初始化的全局和静态C变量和所有被初始化为0的全局或静态变量 |
.rodata |
只读数据段 |
.comment |
包含版本控制信息 |
.symtab |
符号表,存放程序中定义和引用的函数和全局变量信息 |
3. 查看符号表
命令:readelf -s hello.o
符号表用来存放程序中定义和引用的函数和全局变量的信息,重定位需要引用的符号在其中声明。
4. 查看重定位条目
命令:readelf -r hello.o
描述了需要进行重定位的各种信息,包括需要进行重定位符号的位置、重定位的方式、名字。在hello.o中,对printf,exit等函数的未定义的引用替换为该进程的虚拟地址空间中机器代码所在的地址。
objdump -d -r hello.o分析hello.o的反汇编。
机器语言是二进制机器指令的集合,而机器指令由操作码和操作数构成。
机器语言与汇编语言的映射关系:每一条汇编语言操作码都可以用机器二进制数据来表示,所有的汇编语言和二进制机器语言是一一映射关系。
分支转移:
反汇编代码跳转指令的操作数使用的不是段名称如.L3,段名称只是在汇编语言中便于编写的助记符。而在机器语言反汇编程序中,分支转移命令是直接跳转入目的地址。
函数调用:
在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标是相对地址。因为.c文件中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,反汇编的代码已经知道了相对位置。在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0。
本章介绍了hello从hello.s 到hello.o 的汇编过程,通过查看hello.o的ELF格式和使用objdump得到反汇编代码与hello.s进行比较的方式,了解到从汇编语言映射到机器语言汇编器需要实现的转换。在这个过程中,生成了重定位条目,为之后的链接中的重定位过程奠定了基础。
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。
作用:通过链接可以实现将头文件中引用的函数并入到程序中,解析未定义的符号引用,将目标文件中的占位符替换为符号的地址。完成程序中各目标文件的地址空间的组织。
链接命令:
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
结果如下图所示:
读取hello的ELF, 可以看到保存了可执行文件hello中的各个节的信息。如图5-2所示。hello文件中的节的数目比hello.o中多了很多,说明在链接过后有新文件添加进来。
hello的ELF头和hello.o的ELF头大体一致,但是类型从REL (可重定位文件)变为了EXEC (可执行文件),增加程序头起点,节头和字符串表索引节头的数量变多。
使用edb加载hello,查看本进程的虚拟地址空间各段信息。
可以看出.interp段在虚拟地址0x4002e0处,.dynstr段在0x400398处,.init段在0x401000处等。
同时在Data Dump中查看hello的虚拟地址空间开始处为0x401000。
objdump -d -r hello得到hello的反汇编文件:
hello.o的重定位项目:
从这段汇编代码中可以看到:
hello.o中的je,call,jmp后面跟的操作数是全0,而hello中是已经计算出来的相应段或函数的地址。根据这个不同可以分析出hello.o链接成为hello的过程中需要对重定位条目进行重定位,对相应的条目进行计算得到地址。并且hello的反汇编代码还有很多其他的函数,例如puts,printf,getchar等,从这个不同可以分析出链接会将共享库中函数的汇编代码加入hello.o中。
hello重定位的过程:
(1)重定位节和符号定义链接器将所有类型相同的节合并在一起后作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
(2)重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
(3)重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。代码的重定位条目放在.rela.txt
重定位算法:
foreach section s{
foreach relocation entry r{
refptr = s + r.offset;/*ptr to reference to be relocated*/
if(r.type == R_X86_64_PC32){//PC相对寻址的引用
refaddr = ADDR(s) + r.offset;/*ref's run-time address*/
*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr);
}
/*Relocate an absolute reference*/
if( r.type == R_X86_64_32){//使用32位绝对地址
*refptr = (unsigned)(ADDR(r.symbol) + r.addend);
}
}
重定位地址计算公式为:
refaddr = ADDR(s) + r.offset
*refptr = (unsigned) (ADDR(r.symbol) + r.addend - reffaddr)
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。
程序名称 |
程序地址 |
ld-2.27.so!_dl_start |
0x7ffff7a03b00 |
ld-2.27.so!_dl_init |
0x7ffff7de37d0 |
libc-2.27.so!__libc_start_main |
0x7ffffc827ab0 |
-libc-2.27.so!__cxa_atexit |
0x7ffffc849430 |
libc-2.27.so!_setjmp |
0x7ffffc844c10 |
libc-2.27.so!exit |
0x7ffffc849128 |
动态链接就是要将程序拆成几个独立的部分,在运行的时候将它们连接起来,与静态链接把所有模块都链接成一个可执行文件不同。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。如果一个目标模块调用定义在共享库中的任何函数,那么就有自己的GOT和PLT。
PLT是一个数组,其中每个条目是16字节代码。每个库函数都有自己的PLT条目,PLT[0]是一个特殊的条目,跳转到动态链接器中。从PLT[2]开始的条目调用用户代码调用的函数。
GOT也是一个数组,每个条目是8字节的地址,和PLT联合使用时,GOT[2]是动态链接在ld-linux.so模块的入口点,其余条目对应于被调用的函数,在运行时被解析。每个条目都有匹配的PLT条目。
在elf文件中找到.got的地址0x403ff0
本章介绍了链接的概念和作用,详细介绍了hello.o如何成为可执行的目标文件,详细介绍了hello.o的ELF形式和各节的意义,分析了hello的虚拟地址空间、重置进程、运行进程和动态链接过程。
进程是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程的作用:
1.每次用户向shell输入一个可执行目标文件的名字运行时,shell就会创建一个新的进程,然后在这个进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。
2.给应用程序两种关键抽象:
一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
作用:
Shell是一个用C语言编写的程序,他是用户使用Linux的桥梁。Shell是一个交互型应用级程序,代表用户运行其它程序。
处理流程:
Shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,或者是自由软件)。然后Shell在可执行程序的目录列表里寻找这些应用程序。如果键入的命令不是一个内部命令并且在列表里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。
Shell通过fork创建一个新的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库和用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。子进程与父进程最大的区别就是有不同的pid。
fork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0。父进程与子进程是并发运行的独立进程。
execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到hello,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。
加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分存在的区域结构。
2. 映射私有区域。新建应用程序的代码、数据、bss和堆放区域的新区域结构。所有这些区域结构都是私人的,写的时候都是复印的。虚拟地址空间的代码和数据区域将被映射到Hello文件的.txt和.data区域。bss区域请求二进制0。 地图匿名文件。那个大小包含在Hello文件里。堆栈和堆栈区域也请求初始长度为0的二进制数。
3. 映射共享区域。如果hello程序与共享对象相连,例如标准C库libc.so所有对象都将被动态地连接到该程序中,并反映到用户的虚拟中。地址空间的共享区域。
4.设置程序计数器。exceve最后做的就是将当前进程上下文中的程序柜台设置为指向代码区域的入口。调用此进程从这个入口开始实行。Linux可根据需要更换代码和数据页面。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
上下文信息:
上下文是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
进程时间片:
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
上下文切换:
当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:
1.保存以前进程的上下文。
2.恢复新恢复进程被保存的上下文。
3.将控制传递给这个新恢复的进程 ,来完成上下文切换。
用户模式和内核模式:
处理器通常是用某个控制寄存器中的一个模式位提供两种模式的区分功能,该寄存器描述了进程当前享有的特权。当设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。
hello程序的运行:
进程调用execve函数,为hello分配好了虚拟地址空间,将代码段和数据段映射为可执行文件hello中的相应内容。假设hello进程在sleep之前一直在顺序执行。在执行到sleep函数的时候,切换到内核模式,将hello进程挂起,然后切换到用户模式执行其它进程。一段时间后发生中断,切换到内核模式,继续运行之前被挂起的进程。最后切换回用户模式,继续运行hello进程。
hello执行过程中会出现的异常:
中断:信号SIGTSTP,默认行为是 停止直到下一个SIGCONT
终止:信号SIGINT,默认行为是 终止
程序正常运行的结果如下图所示,程序执行完后进程被回收,再按回车键退出程序。
运行时乱按时的结果如下图所示,乱按的输入并不会影响进程的执行,当按到回车键时,getchar会读入回车符,并且后面的字符串会当作shell的命令行输入。
运行时按Ctrl+Z后结果如下图所示。按下Ctrl+Z后父进程收到SIGTSTP信号,将hello进程挂起,ps命令列出当前系统中的进程。运行jobs命令列出当前shell环境中已启动的任务状态。
运行pstree命令,以树状图显示进程间的关系:
运行时按Ctrl+C后结果如下图所示。父进程收到SIGINT信号,终止hello进程,并且回收hello进程。
本章介绍了进程的概念及作用,shell的作用及其处理流程,并分析了hello的fork进程创建过程、execve过程和进程执行过程,最后根据不同情况分析了hello运行过程中的异常和信号处理。
1.逻辑地址:
逻辑地址是指由程序产生的与段相关的偏移地址部分。逻辑地址由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离。
2.线性地址:
线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
3.虚拟地址:
虚拟地址是程序保护模式下,程序访问存储器所使用的逻辑地址称为虚拟地址,与实地址模式下的分段地址类似,虚拟地址也可以写为“段:偏移量”的形式,这里的段是指段选择器。就是hello里面的虚拟内存地址。
4.物理地址:
CPU通过地址总线的寻址,找到真实的物理内存对应地址。在前端总线上传输的内存地址都是物理内存地址。
在保护模式下,段描述符占8个字节,无法直接存放在段寄存器中(段寄存器只有2字节)。X86的设计是段描述符存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值。一个逻辑地址由两部份组成,段标识符: 段内偏移量。
逻辑地址到线性地址的变换方法:
1. 给定一个完整的逻辑地址[段选择符:段内偏移地址],首先根据T1的值,确定当前要转换是GDT中的段,还是LDT中的段,再依据对应寄存器,得到其地址和大小。
2. 根据段选择符中前13位,在数组中查找到相应的段描述符,获得基地址。
3. 将基地址加上偏移量得到要转换的线性地址。
线性地址(即虚拟地址VA)到物理地址(PA)之间的转换通过分页机制完成。而分页机制是对虚拟地址内存空间进行分页。
Linux系统有自己的虚拟内存系统。Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。
系统将每个段分割为被称为虚拟页(VP)的大小固定的块来作为进行数据传输的单元,在linux下每个虚拟页大小为4KB,类似地,物理内存也被分割为物理页(PP/页帧)。虚拟内存系统中MMU负责地址翻译,MMU使用存放在物理内存中的被称为页表的数据结构将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
不考虑TLB与多级页表,虚拟地址分为虚拟页号VPN和虚拟页偏移量VPO,根据位数限制分析可以确定VPN和VPO分别占多少位是多少。通过页表基址寄存器PTBR+VPN在页表中获得条目PTE,一条PTE中包含有效位、权限信息、物理页号,如果有效位是0+NULL则代表没有在虚拟内存空间中分配该内存,如果是有效位0+非NULL,则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中,如果有效位是1则代表该内存已经缓存在了物理内存中,可以得到其物理页号PPN,与虚拟页偏移量共同构成物理地址PA。
Intel i7的地址翻译用到了TLB与四级页表的技术,其中虚拟地址48位,物理地址52位,页表大小为4KB,页表为4级。
由于页表大小是4KB,所以VPO为12位,VPN就是36位。每一个PTE条目是8字节,所以每一个页表就有512个条目,那么VPN中每一级页表对应的索引就有9位。TLB有16组,所以TLBI就是4位,TLBT就是32位。
CPU产生虚拟地址VA,传给MMU,MMU使用前36位作为VPN,在TLB中搜索,如果命中,就得到其中的40位物理页号PPN,与12位VPO合并成52位的物理地址PA。
如果TLB没有命中,MMU就向内存中的页表请求PTE。CR3是一级页表的起始地址,VPN1是第一级页表中的偏移量,用来确定二级页表的起始地址,VPN2又作为第二级页表的偏移量,以此类推,最后在第四级页表中找到对应的PPN,与VPO组合成物理地址PA。
L1 Cache是8路64组相连高速缓存。块大小64B。因为有64组,所以需要6 位CI进行组寻址,共有8路,块大小为64B,所以需要6位CO表示数据偏移位置,VA共52位,所以CT共40位。
在上一步中已经获得了物理地址VA,使用CI进行组索引,每组8路,对8路的块分别匹配CT(前40位)。如果匹配成功且块的valid标志位为1,则命中,根据数据偏移量CO(后六位)取出数据返回。
如果没有匹配成功或者匹配成功但是标志位是0,则不命中,向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。
fork函数为新进程创建各种数据结构,并给它分配一个唯一的PID。为了给新的hello进程创建虚拟内存,它创建了当前进程的的mm_struct, vm_area_struct和页表的原样副本,两个进程中的每个页面都标记为只读,两个进程中的每个区域结构都标记为私有的写时复制。fork在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存,随后的写操作通过写时复制机制创建新页面。
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。图7-7描述了加载器映射用户地址空间区域的模型。加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域:
删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域:
为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
3.映射共享区域:
hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC):
设置当前进程上下文的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,将从这个入口点开始执行。
当指令引用一个相应的虚拟地址,而与改地址相应的物理页面不在内存中,就会引发缺页故障。
假设MMU在试图翻译某个地址时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
1.检查虚拟地址是否合法。该步骤主要检查虚拟地址是否是指向某个区域结构定义的区域内,即是否在访问范围内。如果不在区域内,访问的页是不存在的,则会报错,并终止进程。
2.要进行的内存访问是否合法。主要判断是否满足读、写或者执行这个区域内页面的权限。例如,如果是因为试图对一个只读页面进行写操作而引起的缺页中断,那么缺页处理程序会触发一个保护异常,并终止这个程序。
3.在完成上面两个步骤成功到达第三个步骤时,已经确定了缺页是由于正常的不命中原因引起的,此时便可以从磁盘中将缺失的页读入内存。首先选择一个牺牲页面,如果牺牲页面有被修改过,则将牺牲页面写回磁盘,否则直接还如新的页。
这样,当却也处理程序返回时,CPU会重启引起缺页的指令,而这时就没有了缺页的情况。
缺页故障处理流程。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存空间,其要么是已分配的,要么是空闲的。动态内存分配主要有两种基本方法与策略:
1.隐式空闲链表
隐式空闲链表通常带有一个头部和脚部标签,内容相同,记录块的大小和是否分配等信息。一般来说每个块都是由头部和脚部、有效载荷、可能的额外填充组成,对于某些优化的链表,已分配的块可以没有脚部。
在隐式空闲链表中,所有的块都是通过头部和脚部中的大小字段连接着的。因此分配器可以依次遍历整个堆。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配合最佳适配。分配完后可以分割空闲块减少内部碎片。同时分配器在面对释放一个已分配块时,可以合并空闲块,其中便利用隐式空闲链表的边界标记来进行合并。
2.显示空间链表管理
显式空闲链表是将空闲块组织为某种形式的显式数据结构。堆被组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。
在显式空闲链表中,可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表。其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
本章讨论了存储器地址空间,虚拟地址、物理地址、线性地址、逻辑地址的概念,TLB与四级页表支持下的VA到PA的变换,三级Cache支持下的物理内存访问,缺页故障与缺页中断处理和动态存储分配管理。还了解了一个新的进程在被创建或者是一个程序被加载时,系统如何为其分配和创建存储空间,以及私有文件和共享文件在存储和读写时候的不同机制。
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能统一且一致的来执行。
1.打开和关闭文件
打开文件:进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件的,int open(char *filename, int flags, mode_t mode),open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
关闭文件:进程通过调用close 函数关闭一个打开的文件。int close(int fd)。
2. 读写文件
应用程序是通过分别调用read 和write 函数来执行输入和输出的。
ssize_t read(int fd, void *buf, size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
ssize_t write(int fd, const void *buf, size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
3. 读取文件元数据
应用程序可以通过调用stat和fstat函数,检索到关于文件的信息(元数据)。stat函数以一个文件名作为输入,并填写一个stat数据结构中的各个成员。Fstat函数是相似的,只不过是以文件描述符而不是文件名作输入。
int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);
printf函数功能:接受字符串指针数组fmt,然后将匹配到的参数按照fmt格式输出,返回字符串的长度。printf内部调用了两个外部函数:vsprintf和write。
vsprintf的作用是格式化接受确定输出格式的字符串fnt。用格式字符串对个数变化的参数进行格式化,产生格式化输出,并返回要打印的字符串的长度。
write(buf,i)执行写操作,把buf中的i个元素的值输出。write函数中,先给寄存器传递参数,然后执行系统调用syscall。
syscall函数将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序根据ASCII找到字模库相应的字形,并将每一个点的RGB颜色信息写入到显示vram,然后系统显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
getchar函数通过调用read函数返回字符。其中read函数的第一个参数是描述符fd,0代表标准输入。第二个参数输入内容的指针,这里也就是字符c的地址,最后一个参数是1,代表读入一个字符,符号getchar函数读一个字符的设定。read函数的返回值是读入的字符数,如果为1说明读入成功,那么直接返回字符,否则说明读到了buf的最后。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
本章简述了Linux的I/O设备管理机制,Unix I/O接口及函数,并简要分析了printf函数和getchar函数的实现。
hello所经历的过程:
1.编写:用C语言编写hello.c文件。
2.预处理:预处理器将hello.c调用的所有外部的库展开合并到一个hello.i文件中。
3.编译:编译器(ccl)将解释完预处理指令的hello.i文件进行编译,得到汇编程序(文本)hello.s文件。
4.汇编:汇编器(as)将汇编程序hello.s中的汇编语言转换成机器语言,生成重定位信息,将这些代码和信息生成为一个可重定位目标程序hello.o。
5.链接:链接器(ld)将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello。
6.创建进程:在shell命令行输入./hello,shell解释命令并调用fork函数创建一个子进程。
7.加载程序:加载器调用execve函数,在当前进程(新创建的子进程)的上下文中运行hello程序。
8.内存管理:运行hello时,内存管理单元MMU、翻译后备缓冲器TLB、多级页表机制、三级cache等计算机中的各个组成部件共同运转和配合,完成对内存地址的解析、请求、返回、访问。
9.异常处理:如果运行中键入Ctrl + C或Ctrl + Z,则调用shell的信号处理函数分别停止、挂起。如果产生缺页异常,则缺页处理程序选择合适的牺牲页替换,并重新加载相应命令。
10.结束:当hello运行完毕,向父进程shell发送SIGCHLD信号,提示进程已终止,然后父进程回收hello,内核删除为hello进程创建的所有数据结构。
经过计算机系统这门课的学习,思考,实践,我对于计算机系统的运行机制,程序的编译、链接机制;操作系统的相关知识,Linux的相关操作与系统调用函数等。作为一个计算机专业的学生,了解这样的底层知识才会对程序有一个正确的认识,写出高质量,高性能的代码。
列出所有的中间产物的文件名,并予以说明起作用。
文件名称 |
文件说明 |
hello.c |
hello源文件 |
hello.i |
预处理后文本文件 |
hello.s |
编译得到的汇编文件 |
hello.o |
汇编后的可重定位目标文件 |
hello |
链接后可执行文件 |
hello.objdump |
hello可执行文件反汇编代码 |
hello.elf |
hello的elf文件 |
[1] Linux C 常用库函数手册
Open Source Guides - Linux Foundation
[2] UTF-8编码规则 UTF-8编码规则(转)_vincent_smm的博客-CSDN博客
[3] ELF文件格式解析ELF文件格式解析_mergerly的博客-CSDN博客_elf文件格式.
[4] 内存地址转换与分段内存地址转换与分段_drshenlei的博客-CSDN博客
[5] Linux下逻辑地址、线性地址、物理地址详细总结Linux下逻辑地址、线性地址、物理地址详细总结_FreeeLinux的博客-CSDN博客_linux 物理地址 线性地址 逻辑地址
[6] printf 函数实现的深入剖析 [转]printf 函数实现的深入剖析 - Pianistx - 博客园
[7] Linux的jobs命令 Linux的jobs命令_SnailTyan的博客-CSDN博客_jobs指令
[8] 兰德尔E.布莱恩特,大卫R.奥哈拉伦. 深入理解计算机系统. 北京:机械工业出版社,2016年11月第1版.