目录
摘要
第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下汇编的命令
gcc –c –o hello.o hello.s
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本章小结
结论
附件
hello.c是一般计算机初学者所编写的第一个程序,本文追述hello.c从产生到结束的一生,即hello.c这个源程序所经历的预处理、编译、汇编、链接这一系列过程。同时还包括了它的进程管理、存储管理、IO管理。通过对上述内容观察与描述,来加深对程序的编译、加载、运行的了解
关键词:预处理;编译;汇编;链接;进程管理;存储管理;IO管理
1.1.1 P2P( From Program to Process )
随着一行行代码被敲入,一个名为hello.c的源程序就此诞生,它以文本的形式存在。然后由预处理器cpp寻找以字符#开头的命令,对源程序进行修改,比如将头文件的内容插入源程序文本中,结果得到了以文本形式存在的预处理文件hello.i。接者编译器cll将hello.i翻译成汇编程序hello.s,汇编代码是机器代码的文本表示,描述机器指令,可被人所读懂。然后汇编器as将hello.s变成机器码,将其打包成可重定位的目标文件hello.o,该文件是一个二进制文件,人类是无法直接读懂的,但是我们可以通过反汇编器来查看。再接着链接器ld将目标文件进行合并,获得了可执行目标程序hello。在Linux系统中内置命令行解释器shell加载运行hello程序,shell会调用fork,execve函数为hello产生新进程
1.1.2 020(From Zero-0 to Zero-0)
Shell在调用execve并加载运行hello,为其分配虚拟内存空间,映射虚拟内存,MMU组织各级页表与cache为其开路,给予hello想要的所有信息,CPU为运行的hello分配时间片执行控制逻辑流。当程序运行结束后,父进程shell回收hello的进程,内核清除与hello相关的数据结构。
1.2.1硬件环境
X64 CPU;2.10GHz;16.0 GB RAM
1.2.2软件环境
Windows 10 64位;VM VirtualBox 6.1;Ubuntu 20.04 LTS 64位;
1.2.3开发与调试工具
vim,gcc,as,ld,edb,readlf,objdump
文件名称 |
文件作用 |
hello.c |
源程序文本 |
hello.i |
预处理生成的文件 |
hello.s |
编译后的汇编文件 |
hello.o |
汇编后的可重定位的目标文件 |
hello |
链接后的可执行文件 |
hello_dis1.txt |
hello.o反汇编后得到的文件 |
hello_dis2.txt |
hello反汇编后得到的文件 |
hello.elf |
hello.o文件的ELF格式 |
hello_elf.txt |
hello文件的ELF格式 |
本章对hello进行了简单的介绍,分析了其P2P和020的过程,列出了本次任务的环境和工具,并且阐明了任务过程中出现的中间产物及其作用。
2.1.1 概念
在编译之前对源代码进行的处理:
1.宏定义,将所有的#define删除,并且展开所有的宏定义;
2.文件包含;处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。该过程递归进行,及被包含的文件可能还包含其他文件。
3.条件编译,处理所有条件编译指令,如#if,#ifdef等;
4.删除所有的注释//和 /**/;
5.加行号和文件标识,如#2 “hello.c” 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号信息;
6.保留所有的#pragma编译器指令,因为编译器须要使用它们。
2.1.2 作用
预处理是指预处理器(cpp)根据以字符#开头的命令,对源程序进行修改,比如#include
gcc -E hello.c -o hello.i
生成hello.i文件
hello.i在原有代码的基础上,删除所有注释,用实际值代替#define定义的字符串,条件编译,处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。该过程递归进行。
main函数出现在.i文件最后
介绍了编译器预处理的相关概念,使用Ubuntu下的预处理指令将hello.c转换为.i文件
3.1.1 概念
编译器将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。它是以高级程序设计语言书写的源程序作为输入,以汇编语言或者机器语言表示的目标程序作为输出,这个过程称为编译。
编译就是把高级语言变成计算机可以识别的2进制语言,计算机只认识1和0,编译程序把人们熟悉的语言换成2进制的。工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
3.1.2 作用
编译的作用是产生可执行程序的第一步,将预处理文件编译为汇编原始文件,以便进行接下来的工作。
gcc -S hello.i -o hello.s
生成了hello.s文件
3.3.1 数据
1.常量
(1)字符串
一共有两个字符串,都是printf函数的输出内容,存储在.rodata只读数据节中
LC0:"用法: Hello 学号 姓名 秒数!\n",汉字使用utf-8编码,一个汉字占三个字节
LC1:"Hello %s %s\n"
(2)立即数
在程序中直接出现的整数,以$xxx形式出现
2.变量
(1)局部变量
int i是循环时候的计数变量,for(i=0;i<8;i++),初值为0,从0变到7,编译器将局部变量存储在寄存器或栈空间中。hello.s将i存储在-4(%rbp)中。
(2)main函数传入参数
int argc是从终端输入的参数个数,是main函数的第一个参数,由%rdi保存
char *argv[]是存放char指针的数组,是main函数的第二个参数,由%rsi保存
3.3.2赋值
局部变量int i在定义时并没有初始化,在for循环中由movl赋值为0
movl,movq分别是和8个字节的数据进行赋值操作
3.3.3类型转换
调用atoi函数将argv[3]字符串转为整型
3.3.4算术操作
hello.c中有一处算术操作,for循环中实现i++
3.3.5关系操作
hello.c中有两处关系操作
(1)for循环中i<8
与立即数7进行比较,若小于等于则跳转.L4继续循环
(2)argc!=4
将argc与立即数4进行比较,若相等则跳转.L2
3.3.6数组/指针/结构操作
hello.c中argv[]数组的首地址存储在-32(%rbp)中,每次访问argv数组中的第i个元素时,用首地址加上数据元素的大小*i来进行访问
(1)for循环体中printf的两个参数为agrv[1]和argv[2]
(2)for循环体中sleep函数参数为atoi(argv[3])
3.3.7控制转移
hello.c中有两处控制转移
(1)if语句中判断argc!=4,要是等于4,则跳转到.L2
(2)for循环中判断i<8,若小于则执行循环体
3.3.8函数操作
(1)main函数
开始被存放在.text节中,标记为函数,传入参数为argc和argv[],分别存储在%rdi和%rsi中
(2)printf函数
第一个printf仅输出字符串,在汇编中被优化为puts函数
第二个printf有三个参数,输出字符串存储在%rdi,arg[1]存储在%rsi,argv[2]存储在%rdx
(3)sleep函数
将执行挂起一段时间,其参数是atoi(argv[3])函数的返回值
(4)atoi函数
将字符串转为整型,参数是argv[3]
(5)exit函数
若用户输入的参数不是四个,则调用exit函数,exit(1)表示程序异常退出
(6)getchar函数
该函数没有参数,待循环结束后调用
介绍了编译器是如何处理C语言中各式各样的数据类型以及操作的,明白我们编写的代码是怎么在机器上执行的,一个功能是由哪些机器指令所构成的
4.1.1 概念
把汇编语言书写的程序翻译成与之等价的机器语言程序。汇编程序输入的是用汇编语言书写的源程序,输出的是用机器语言表示的目标程序
4.1.2 作用
将汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,是产生可执行文件的第二步
gcc –c –o hello.o hello.s
生成了hello.s文件
使用readelf -a hello.o>hello.elf指令获得hello.o文件的ELF格式
生成hello.elf
1.ELF头(ELF header)
以16B的序列Magic开始,该序列描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
可以看到目标文件类型是REL,机器类型是X86-64等等
2.节头部表(section header table)
描述了不同节的大小,类型与位置,从下表可以看出elf文件中共有13个节,个节的类型、位置和大小等信息都涵盖其中。由于是可重定位目标文件,所以每个节都从0开始,用于重定位。
3.重定位节
描述了需要进行重定位的各种信息,包括需要进行重定位符号的位置、重定位的方式、名字。
4.符号表symtab
存放程序中定义和引用的函数和全局变量的信息。包含用于重定位的信息,符号名称,符号是全局变量还是局部,标识符号对应的类型
name 是字符串表中的字节偏移。指向符号的以null 结尾的字符串名字。value是符号的地址。对于可重定位的模块来说,value 是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址。size 是目标的大小(以字节为单位),type通常要么是数据,要么是函数。符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目。所以这些目标的类型也有所不同。
binding 字段表示符号是本地的还是全局的。Ndx:ABS表示不该被重定位、UND表示未定义(在这个地方被引用,但是在其他地方进行定义)、COM表示未初始化数据(未初始化的全局变量)
命令:objdump -d -r hello.o (生成hello_dis1.txt:objdump -d hello.o>hello_dis1.txt)
机器语言是二进制机器指令的集合,而机器指令是由操作码和操作数构成的,每一条汇编语言操作码都可以用机器的二进制数据来表示,进而可以将所有汇编语言的操作码与操作数和二进制机器语言建立一一映射的关系
对比反汇编以及hello.s,两者大致相同,但仍存在些许差异
(1)分支转移
反汇编中分支转移是直接转入目的地址,而在hello.s文件是用.L1,.L2等符号来标识的
(2)函数调用
由于hello.c中调用的函数都是共享库中的函数,在连接后才能确定函数的最终位置,所以反汇编语言调用函数是跳转到目标地址,而汇编语言后面跟着的是函数名称
(3)数据访问
在汇编语言中,访问.rodata(printf中的字符串),使用段名称+%rip,而在机器语言对应的反汇编程序中为0+%rip。因为.rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
(4)数字表示
反汇编中的操作数是由十六进制,而hello.s中是10进制的
通过将hello.s汇编指令转换成hello.o机器指令,通过readelf查看hello.o的ELF,了解ELF的内容与组成。再使用反汇编的方式查看hello.o反汇编的内容,比较其与hello.s之间的差别,通过对比这些不同,更好的了解了重定位,为学习链接有了更好的准备。以及学习汇编指令和机器指令之间的映射关系,更深刻地理解了汇编语言到机器语言实现地转变。
5.1.1 概念
将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时(compte tmer)也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(runtime),也就是由应用程序来执行。
5.1.2 作用
当程序调用函数库(如标准C库)中的一个函数printf,printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个函数必须通过链接器(ld)将这个文件合并到hello.o程序中,结果得到hello文件,它是一个可执行目标文件,可以被加载到内存中,由系统执行。另外,链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。
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
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
生成hello_elf.txt:readelf -a hello>hello_elf.txt
1.ELF头(ELF header)
命令:readelf -h hello
ELF头中主要介绍了程序节表大小、节表数量、数据存储方式等等与文件整体框架相关的信息
hello文件类型为EXEC,是一个可执行文件,文件中共有27个节
2.节头表
命令:readelf -S hello
可以得知文件中各个段的基本信息,比如由size可以获得各个段的大小,由offset可以获得各个段在程序的偏移量,下图仅截出了一部分
使用edb加载hello,通过edb中的Data Dump窗口查看加载到虚拟地址中的hello程序,查看0X00400000到0X00400fff之间内容,开头为ELF头
根据5.3中的图,可以得知.interp节对应的地址是0x004002e0,大小为0X1c
同理.note.gnu.propert,开始的地址是0X0040300,大小为0x20
5.5.1命令
使用objdump -d -r hello 分析hello与hello.o的不同
(生成hello_dis2.txt:objdump -d hello>hello_dis2.txt)
5.5.2不同处
hello.o与hello生成的反汇编代码大致相同,但有以下几种不同:
(1)hello的反汇编相较于hello.o的反汇编多出了许多文件节,hello.o反汇编中只有.text节。.init节定义了一个_init函数,程序初始化会调用它
(2)hello反汇编代码有确定的虚拟地址,而hello.o反汇编代码中虚拟地址均为0。地址有相对偏移变为了可以由CPU直接访问的虚拟地址
(3)在.text节中,多了一个函数_start,加载程序时,加载器会跳转到_start的地址,其会调用main函数
5.5.3分析重定位过程
(1)合并相同节。重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。函数<_start>就是系统代码段(.text)与hello.o中的.text节合并得到的最后的一个单独的代码段。
(2)确定新节中所有定义符号在虚拟地址空间中的地址。链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
(3)重定位节中的符号引用。连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。
(4)重定位条目。当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。
(1)在ubuntu中打开终端,进入当前路径,输入命令: gdb hello
(2)输入命令rbreak,该命令是向程序所有函数设置断点
(3)输入命令run argv
这里argv指的是你输入的参数,比如输入命令:run 120L020428 lin 2
再逐行输入continue,直至程序运行结束
程序名 |
程序地址 |
_init |
0x401000 |
_start |
0x4010f0 |
__libc_csu_init |
0x401270 |
_init |
0x401000 |
frame_dummy |
0x4011d0 |
register_tm_clones |
0x401160 |
main |
0x4011d6 |
puts@plt |
0x401090 |
exit@plt |
0x4010d0 |
printf@plt |
0x4010a0 |
atoi@plt |
0x4010c0 |
sleep@plt |
0x4010e0 |
getchar@plt |
0x4010b0 |
__do_global_dtors_aux |
0x4011a0 |
deregister_tm_clones |
0x401130 |
_fini |
0x4012e8 |
动态链接下对于全局变量和静态数据的访问都需要进行GOT定位后才能跳转,GOT中保存的是函数的目标地址。程序首先计算got.plt的地址,然后在函数调用时更新重定位节并将对应的地址标记为目标函数的符号,然后got.plt地址加上一个偏移量得到got.plt中目标函数的地址,再根据地址就可以取出函数例程的第一条指令地址,完成动态链接函数调用
可知.got.plt节的起始地址为0x00404000,大小为0x48
在Data Dump中找到该位置
调用调用_init函数后,从地址0x00404008处,由原来的00 00 00 00 00 00 变为90 31 70 25 9e 7f;由原来的00 00 00 00 00 00变为f0 ca 6e 25 9e 7f由于小端的缘故,则这两处的地址应该是0x7f 9e 25 70 31 90 ,0x7f 9e 25 6e ca f0,这就是GOT[1]和GOT[2]
在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问跳转时,GOT 地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在 PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位 表确定函数运行时地址,重写 GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数
介绍了链接的概念和作用,在Ubuntu下链接的命令行。对hello的ELF格式进行了详细的分析对比。分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。链接可以执行于编译时,也可以执行于加载时,甚至执行于运行时。经过链接,ELF可重定位的目标文件变成可执行的目标文件,链接器会将静态库代码写入程序中,以及动态库调用的相关信息,并且将地址进行重定位,从而保证寻址的正确进行。
6.1.1概念
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量
6.1.2作用
给应用程序提供两个关键抽象:一个独立的逻辑控制流,一个私有的存储空间。在操作系统上运行一个程序时,就好像程序是系统中当前运行的唯一的程序,好像是在独占处理器和内存。
6.2.1作用
作为命令处理器,接受用户输入的命令,然后根据命令进行相关操作,比如调用相关的程序。作为命令语言,它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令。作为程序设计语言,它定义了各种变量和参数,并提供了许多在高级语言中才具有的控制结构,包括循环和分支
6.2.2处理流程
(1)从终端读入用户输入的命令
(2)对命令解析,并判断其是否为内置命令
(3)若是内置命令,则直接执行
(4)若不是,则调用execve函数创建子进程运行
(5)再判断是否为前台运行程序,若是,则调用等待函数,等待前台程序结束。否则程序转入后台,接受用户下一步输入的命令
(6)Shell接受键盘输入的信号,并且应该对信号产生相应的反应
(7)回收僵死进程
在终端输入./hello,shell进行对命令行的解释,因为不是内置shell命令,因此调用fork()函数创建一个新的运行子进程,执行可执行程序hello。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的但独立一份副本,包括代码段、段、数据段、共享库以及用户栈子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的,fork函数在子进程中返回0
子进程调用execve函数,使用驻留在储存器中的称为加载器的操作系统代码来加载并运行可执行目标文件hello,并映射私有区域,为程序的代码,数据,bss,栈区域创建新的区域结构。代码和数据区域映射为hello中的.text,.data区,bss请求二进制零,映射到匿名文件。栈和堆请求二进制零,初始长度为0。
接着,映射共享区域;最后设置当前进程上下文中的程序计数器PC,指向代码区域的入口点即-start函数的地址。start函数调用系统启动函数 _libc_start_main来初始化执行环境,并调用用户层的main函数,此时构造的argv向量被传递给主函数
(1)进程上下文:进程的物理实体(代码和数据等)和支持进程的运行的环境合称为进程的上下文
(2)进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
(3)在内核执行的某些时刻,内核可以决定抢占当前进程,并通过上下文切换重新开始一个先前被抢占的进程,这就是调度。
(4)用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1异常
异常可以分为四类:中断、陷阱、故障、终止,hello程序执行过程中可能出现的异常有:
(1)中断:在hello程序执行的过程中可能会出现外部I/O设备引起的异常。
(2)陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep 函数的时候会出现这个异常。
(3)故障:在执行hello程序的时候,可能会发生缺页故障。
(4)终止:终止时不可恢复的错误,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。
6.6.2信号
SIGINT:Ctrl-Z会产生这个信号,会中断进程,切换到别的进程中;
SIGCONT:使用fg命令,会向进程发送该信号,切换到该进程继续执行(如果停止了).
SIGTRAP:sleep函数会触发这个信号,调用陷阱异常处理程序,并跟踪陷阱.
SIGCHLD:exit函数会发送这个信号,终止进程.
kill可以给根据PID给进程发送信号.
6.6.3分析不同信号的处理
(1)输入参数不是四个,输出“用法:Hello 学号 姓名 秒数!”
(2)输入正确参数,输出“Hello 120L020428 lin”8遍
(3)运行过程中乱按,并不会影响到hello的输出,我们从键盘的输入会被缓存到了输入缓存区,如果乱按且结尾没有回车,这些东西会被当做字符串缓存,而如果结尾是回车,它之前的信息会被当作输入的命令
(4)按Ctrl+Z,发送一个SIGTSTP信号给前台进程组的每个进程,停止前台作业,即停止hello程序
输入ps,查看进程信息,发现hello还在后台
输入jobs,看到进程停止
输入pstree命令,可以找到进程树下的hello进程
(5)按Ctrl+C,让内核发送一个SIGINT信号给到前台进程组中的每个进程,终止前台进程,即终止hello程序。
(6)使用fg将程序调至前台继续运行,程序正常结束
(7)使用kill杀死进程
介绍了进程的概念和作用,简述了shell的作用和处理流程,以及hello的进程执行,hello的异常与信号处理
(1)逻辑地址:CPU所生成的地址。逻辑地址是内部和编程使用的、并不唯一。由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。
(2)线性地址:是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
(3)虚拟地址:CPU启动保护模式后,程序运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。
(4)物理地址:加载到内存地址寄存器中的地址,内存单元的真正地址。在前端总线上传输的内存地址都是物理内存地址,编号从0开始一直到可用物理内存的最高端。
7.2.1 段寄存器
段寄存器(16位)用于存放段选择符
Intel 8086设定4个段寄存器
CS:Code Segment,代码段寄存器
DS:Data Segment,数据段寄存器
SS:Stack Segment,堆栈段寄存器
ES:Extra Segment,附加段寄存器
7.2.2.段选择符
段选择符又称作段选择子,是段的一个16位标识符,段选择符并不直接指向段,而是指向段描述符表中定义段的段描述符,段描述符有64位。也就是说段选择符通过定位段表中的一个段描述符来指定一个段。我们通过段选择符找到段基址。
段选择符中字段的含义
7.2.3逻辑地址转换为线性地址
1. 逻辑地址:逻辑地址有48位的基本形式为[段选择符:段内偏移量],其中段选择符selector占16位,段内偏移量offset占32位
2. 线性地址:线性地址是32位的数据,线性地址= 段地址 + 偏移地址
3. 逻辑地址转线性地址步骤:
7.3.1相关知识
(1)页
线性地址被划分为固定长度单位的数组,成为页。例如一个32位的机器,线性地址可以达到4GB,用4KB为一个页来划分,这样整个线性地址就被划分成一个2^20的大数组,共有2^20个页,我们称之为页表。
(2)表和页目录
页式管理中涉及到的两个线性表,用来查表找到某个线性地址对应的物理地址。CPU在页式管理中引入了两级的页表结构,第一级的页表称之为页目录;第二级的页表用于存放物理内存中页框的基地址。页目录每一项存放的是某个页表的地址,页表中的每一项存放的是某个页的地址。其中第一级的页目录的基址放在CPU的寄存器CR3中。
(3)页框
对应真正的物理地址,以4KB为边界
7.3.2线性地址转物理地址
线性地址有32位,被划分为3个部分:0~11位页内偏移量;12~21位页表;22~31位页目录索引
TLB(翻译后备缓冲器)是一个位于MMU中的小的虚拟地址的具有较高相联度的缓存,其每一行都是一组由数个PTE组成的块,TLB极大地减小了CPU访问PTE的开销,且能实现虚拟页面向物理页面的映射,同时对于页面数很少的页表可以完全包含在TLB中。
Core i7使用四级列表(L1页表、L2页表、L3页表、L4页表)来将虚拟地址翻译为物理地址。36位的VPN被划分为四个9位的片(VPN1、VPN2、VPN3、VPN4),每个片被用作到一个页表的偏移量。CR3(i7中的PTBR)寄存器包含L1页表的物理地址。VPN1提供到一个L1 PTE的偏移量,这个PTE包含一个L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,以此类推。在L4页表的PTE中存了VPN对应的40位PPN,将这个PPN与VPO合并,就得到了VA对应的PA。
(1)得到了物理地址VA,首先使用物理地址的CI进行组索引(每组8路),对8路的块分别匹配 CT进行标志位匹配。如果匹配成功且块的valid标志位为1,则命中hit。然后根据数据偏移量 CO取出数据并返回。
(2)若没找到相匹配的或者标志位为0,则miss。那么cache向下一级cache,这里是二级cache甚至三级cache中寻找查询数据。然后逐级写入cache。
(3)在更新cache的时候,需要判断是否有空闲块。若有空闲块(即有效位为0),则写入;若不存在,则进行驱逐一个块(LRU策略)。
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID 。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork 在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
(1)删除已存在的用户区域:删除当前进程虚拟地址的用户部分中已存在的区域结构。
(2)映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的,写时复制的。代码和数据区域被映射为hello文件中的.test和.data区。bss区域是请求二进制0的,映射到匿名文件,其大小包含在a.out当中。栈和堆区域也是请求二进制零的,初始长度为零。
(3)映射共享区域hello 程序与共享对象 libc.so 链接,libc.s是动态链 接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器:execve做的最后一件事情就是设置当前进程上下文中的程序计数器,指向代码区域的入口点。
缺页故障:当CPU想要读取虚拟内存中的某个数据,而这一片数据恰好存放在主存当中时,就称为页命中。相对的,如果DRAM缓存不命中,则称之为缺页。如果CPU尝试读取一片内存而这片内存并没有缓存在主存当中时,就会触发一个缺页异常。
缺页故障处理:发生缺页故障时,处出发缺页异常处理程序,缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。缺页处理程序调入新的页面,并更新内存中的PTE,缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经缓存在物理内存中,所以就会命中。
动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
1.隐式空闲链表
空闲块通过头部中的大小字段隐含地连接着。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。
(1)放置策略:首次适配、下一次适配、最佳适配。首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
(2)合并策略:立即合并、推迟合并。立即合并就是在每次一个块被释放时,就合并所有的相邻块;推迟合并就是等到某个稍晚的时候再合并空闲块。
2.显式空闲链表
每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。使用双向链表使首次适配的时间减少到空闲块数量的线性时间。
空闲链表中块的排序策略:一种是用后进先出的顺序维护链表,将新释放的块放置在链表的开始处,另一种方法是按照地址顺序来维护链表,链表中每个块的地址都小于它后继的地址。
3.分离的空闲链表
维护多个空闲链表,每个链表中的块有大致相等的大小。将所有可能的块大小分成一些等价类,也叫做大小类。
通过对段式和页式存储,页表的存储管理,虚拟地址物理地址的转换,进程的加载时的内存映射,缺页故障和处理,动态内存分配等一系列关于进程存储问题的讨论,对程序运行时OS对内存的相关管理机制以及进程运行的实现有了一定的理解。
设备的模型化:文件
一个Linux文件就是一个m字节的序列,所有的I/O设备都被模型化为文件,所有的输入和输出都被当作相应文件的读和写来执行。Linux文件的类型有普通文件(regular file)、目录(directory)、套接字(socket)、命名通道(named pipe)、符号链接(symbolic link)、字符(character)、块设备(block device)。
设备管理:unix io接口
将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得输入和输出都能以一种统一且一致的方式的来执行。
(1)打开文件。进程通过调用open函数来打开一个已存在的文件或创建一个新文件。int open(char *filename, int flags, mode_t mode)open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在 进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访 问这个文件,mode参数指定了新文件的访问权限位。
(2)关闭文件。进程通过调用close函数关闭一个文件。
(3)读文件ssize_t read(int fd, void *buf, size_t n)
read 函数从描述符为 fd 的当前文 件位置赋值最多 n 个字节到内存位置 buf。返回值-1 表示一个错误,0 表示 EOF,否则返回值表示的是实际传送的字节数量。
(4)写文件ssize_t write(int fd, const void *buf, size_t n)
write 函数从内存位置 buf 复制至多 n 个字节到描述符为 fd 的当前文件位置。
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
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;
}
(1)在形参列表里有这么一个token:...
这个是可变形参的一种写法。 当传递参数的个数不确定时,就可以用这种方式来表示。 很显然,我们需要一种方法,来让函数体可以知道具体调用时参数的个数。
(2)va_list arg = (va_list)((char*)(&fmt) + 4);
va_list是一个字符指针,而(char*)(&fmt) + 4) 表示的是...中的第一个参数的地址
(3)i = vsprintf(buf, fmt, arg);
vsprintf 返回的是要打印出来的字符串的长度,我们也可以从后一句write(buf,i)推断出来。write,顾名思义:写操作,把 buf 中的 i 个元素的值写到终端。
所以说:vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
(4)write(buf,i)
接着就轮到write系统函数了,在Linux下,write 函数的第一个参数为fd,
也就是描述符,而1代表的就是标准输出。查看write函数的汇编实现可以发现,
它首先给寄存器传递了几个参数,然后执行int INT_VECTOR_SYS_CALL,代表
通过系统调用syscall,syscall将寄存器中的字节通过总线复制到显卡的显存中。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点
(RGB 分量)。由此write函数显示一个已格式化的字符串
(5)sys_call实现
一个call save,是为了保存中断前进程的状态。ecx中是要打印出的元素个数
ebx中的是要打印的buf字符数组中的第一个元素;这个函数的功能就是不断的打印出字符,直到遇到:'\0';[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
(6)字符串显示
字符显式驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中,显示芯片会按照一定的刷新频率逐行读取varm并通过信号线向液晶显示器传输每一个点RGB分量,于是字符串就显示在了屏幕上。
通过分析源码,可以知道getchar内部调用read函数将整个缓冲区都读到了buf中,并用静态变量n来保存缓冲区的长度,当n为0,即buf长度为0时,getchar进行调用read函数,否则直接将保存的buf中的最前面的元素返回。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回
通过本章,了解了linux的I/O设备管理机制,开、关、读、写、转移文件的接口及相关函数,以及printf和getchar函数的实现方法以及操作过程
回顾hello 的一生:用户从键盘输入,得到 hello.c 源文件。编译器对 hello.c 进行预处理,得到hello.i。汇编器对 hello.i 进行预处理,得到hello.s然后对其进行编译和汇编,得到可重定位目标文件 hello.o。链接器对 hello.o 进行链接,并得到可执行目标文件 hello,此时 hello 已经可以被操作系统加载和执行。
bash 执行 hello,首先 bash 会 fork 一个进程,然后在这个新的进程中 execve hello,execve 会清空当前进程的数据并加载 hello,然后把 rip 指向 hello 的程序入口,把控制权交给 hello。hello 与许多进程并行执行,执行过程中由于系统调用或者计时器中断,会导致上下文切换,内核会选择另一个进程进行调度,并抢占当前的 hello 进程。
hello 执行的过程中可能收到来自键盘或者其它进程的信号,当收到信号时hello 会调用信号处理程序来进行处理,可能出现的行为有停止终止忽略等。
hello 输出信息时需要调用 printf 和 getchar,而 printf 和 getchar 的实现需要调用 Unix I/O 中的 write 和 read 函数,而它们的实现需要借助系统调用。
hello 中的访存操作,需要经历逻辑地址到线性地址最后到物理地址的变换,而访问物理地址的数据可能已被缓存至高速缓冲区,也可能位于主存中,也可能位于磁盘中等待被交换到主存。
hello 结束进程后,bash 作为 hello 的父进程会回收 hello 进程,至此 hello的一生到此结束。
我们可以看到即使是一个简单的小程序,也需要经历一系列步骤,经历Editor+Cpp+Compiler+AS+LD + OS + CPU/RAM/IO等的漫长旅途,当然这个时间是很短的。在这个过程中,计算机内部多个元件,多个系统的相互协调合作,才使这个简单的hello程序得以实现。在各元件系统井然有序的配合中,我们也能得以窥见一个精密而庞大的计算机系统
大作业以一个我们熟悉且结构简单的hello.c程序为起点,通过逐步分析hello.c程序到hello可执行文件过程中经历的多个阶段以及hello程序通过shell中的./hello指令开始执行到最终执行结束被回收的全过程,加深了对程序实现过程的理解,也对课程所教授的知识点进行连接,有了整体的掌握。
文件名称 |
文件作用 |
hello.c |
源程序文本 |
hello.i |
预处理生成的文件 |
hello.s |
编译后的汇编文件 |
hello.o |
汇编后的可重定位的目标文件 |
hello |
链接后的可执行文件 |
hello_dis1.txt |
hello.o反汇编后得到的文件 |
hello_dis2.txt |
hello反汇编后得到的文件 |
hello.elf |
hello.o文件的ELF格式 |
hello_elf.txt |
hello文件的ELF格式 |