计算机系统
大作业
题 目 程序人生-hello’s P2P
专 业 计算机类
学 号
班 级
学 生
指 导 教 师
计算机科学与技术学院
2019年12月
摘 要
本文从hello.c的源代码出发,阐述了hello从运行到结束中的各个过程,从预处理、编译、汇编到链接再到运行,在Linux系统下运用edb、objdump等工具进行分析。在运行的阶段,结合书本上的知识,以hello为例,分析说明了hello运行后产生的进程是如何产生结果并打印到屏幕上的,包括fork、execve等函数和hello的异常与从键盘输入的信号的处理,以及程序是如何访问内存和动态申请内存的。希望借此将在CSapp中学习到的理论知识与实际的程序运行联系在一起,获得宏观上的知识体验。
关键词:编译、异常与信号、内存、动态申请内存
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 11 -
7.3 hello的线性地址到物理地址的变换-页式管理 - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 11 -
7.7 hello进程execve时的内存映射 - 11 -
第1章 概述
1.1 hello简介
hello的P2P(From Program to Process):
Program是程序,Process是进程。进程是动态的,程序是静态的:程序是有序代码的集合,而进程是程序的执行[1]。
hello的P2P就是从一个由高级语言编写的程序到动态运行的进程的一个过程,这个过程中分为这样几个阶段:
预处理阶段:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。
编译阶段:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
汇编阶段:接下来,汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。
链接阶段:链接器(ld)将C编译器提供的以.o目标文件形式保存的标准C库提供的函数以某种方式合并到hello.o程序中[2]。
经过以上阶段,一个由C语言编写的文件就被翻译为计算机可以读懂的文件,执行此文件,操作系统会为其fork产生子进程,再调用execve函数加载进程。至此,P2P结束。
hello的O2O:From Zero-0 to Zero-0
hello由程序变为进程之后,可以说是一无所有,这是From Zero-0;由操作系统的虚拟内存机制为其规划空间,调度器为其规划进程执行的时间片,使其能够与其他进程合理共享CPU与内存的资源。
接下来,CPU从hello的.text段中逐条地取指令执行;从.data段取出数据;异常处理监视键盘输入。hello中的syscall系统地调用语句使内核执行进程,执行write函数,将字符串传递给屏幕I/O映射。文件对传入的字符串进行分析,读取vram,然后在屏幕上打印字符,最终程序结束,shell回收进程,完成hello程序的执行,最后在内存中也没有留下什么痕迹,这是to Zero-0。
1.2 环境与工具
(1)硬件环境:X64CPU;2.20GHz;8GRAM;128GB SSD+1TB HD (2)软件环境:Windows10 64位;VMware Workstation Pro;Ubuntu 19.04 64位 (3)使用工具:Dev C++;objdump;GDB
1.3 中间结果
hello.c | hello的C源代码 |
hello.i | hello.c预编译产生的.i文件 |
hello.s | hello.i编译后产生的hello.s汇编代码文件 |
hello.o | hello.s汇编后产生的hello.o可重定位目标文件 |
helloo.elf | hello.o的ELF格式 |
hello.elf | hello的ELF格式 |
hello.objdump | hello经objdump的反汇编代码 |
helloo.objdump | hello.o经objdump的反汇编代码 |
hello.txt | hello经objdump -dxg的反汇编代码 |
hello | hello.o链接之后生成的可执行目标文件 |
1.4 本章小结
本章主要介绍了P2P、020的过程并列出实验基本信息。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)[3]。
预处理的作用:
预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。
2.2在Ubuntu下预处理的命令
命令:gcc -E hello.c -o hello.i
2.1预处理命令
可以看见文件夹中多出了hello.i的文件
2.2预处理文件
2.3 hello的预处理结果解析
2.3 hello的预处理文件内容
可以看到,源代码保持不变,但注释都被删除,并且hello.i中的文本多出了3000+行,增加的文本是三个头文件的源码,在#include命令所包含的头文件都被替代为了相应的代码,这样产生的hello.i文件就是具有能够独立运行的一套源代码,而不是实现功能的代码片段了。
2.4 本章小结
阐述了预处理的概念和作用;介绍了对.c文件预处理的命令和过程,分析比较了hello.c和hello.i文件的相同和不同。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译(compilation, compile):利用编译程序从源语言编写的源程序产生目标程序的过程,这里的编译是指从.i到.s即预处理后的文件到生成汇编语言程序。
编译的作用:
编译就是把高级语言变成计算机可以识别的二进制语言,计算机只认识1和0,编译程序把人们熟悉的语言换成二进制的。
编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息[4]。
3.2 在Ubuntu下编译的命令
命令:gcc -S -o hello.s hello.i
3.1编译命令
可以看见文件夹中多出了一个hello.s文件:
3.2编译后产生文件
3.3 hello的编译结果解析
3.3.1常量、变量等的处理
全局变量的处理
整型变量sleepsecs是代码中的一个全局变量,汇编代码中,全局变量被存放在函数体外的.data段
3.3.data段的全局变量
sleepsecs作为全局变量,类型为object存放在.data节中,占用4字节的内存空间。
常量的处理
hello代码中的两个常量分别是在printf函数中的两个字符串;
3.4 printf函数
3.5 字符串存放位置
这两个字符串存放在汇编程序中的只读数据域。
局部变量和参数的处理
代码中的局部变量为i,以及两个参数int argc, char *argv[]:
3.6 局部变量
3.7参数
在hello.s中无法直接找到局部变量和参数,这些数据不会被外部函数所访问,只会在当前的局部函数中进行读写,因此不需要像全局变量在代码段以外分配空间。通过分析汇编代码可以知道这些数据在栈中存储,并且可以通过寄存器进行传递和修改。
3.8源代码与.s文件对比
由i = 0的初值,每次步长加1和与9比较得知i存放在栈中,并用寄存器 -4(%rbp)进行访问。
3.3.2赋值语句和算术操作的处理
hello代码的for循环中有赋值操作,i赋初值为0,i++算术操作步长加1,同时进行赋值i = i+1,这里面是用汇编语句和访问寄存器完成的。
i赋初值为0:
i++算术操作和赋值i = i+1:
3.3.3关系操作的处理
代码中的关系操作有两处,一为argc != 3,另一为i < 10:
3.9关系操作
关系判断的结果通常用于改变控制流,如作为if语句的判断条件,以及for,while等循环语句中的循环条件。
参数argc与3比较的关系操作对应的汇编代码如下:
3.10关系操作汇编代码1
局部变量i与10比较的关系操作对应的汇编代码如下:
3.11关系操作汇编代码2
因为是i < 10,编译器直接将i与9进行比较,进行了一定程度的优化,不是单纯地解释。
3.3.4数组/指针/结构操作的处理
hello代码中在printf对数组有操作:
3.12对于数组的操作
argv[]是从命令行键入的字符串的地址数组,里面按顺序存放着命令行输入的字符串在内存中的存放地址。由于数组是在内存中一段连续的内存空间中进行存储的,所以汇编语言通过索引值与数组基址来对数组内容进行寻址。
参数argv的数组操作对应的汇编代码如下:
3.13数组操作的汇编代码
两次addq,由于是64位操作系统,指针为8字节,加16的操作访问argv[2],而加8的操作访问argv[1]。
3.3.5控制转移的处理
产生控制转移的情况有两种,分别是分支和循环,在当前函数的代码中对应了if分支判断语句和for循环语句。
3.14分支和循环语句
在汇编代码中进行控制转移主要依靠跳转语句,跳转指令如下:
3.15跳转指令
if(argc != 3)对应的汇编语句:
3.16分支对应汇编语句
for(i = 0; i < 10; i++)对应的汇编语句:
3.17循环对应汇编语句
3.3.6函数操作的处理
函数的操作分为调用、传参和返回三个部分。
给函数传参需要设定寄存器,将参数传给所设的寄存器,再通过call跳转到调用的函数地址。在hello代码中调用了printf、exit()、sleep和getchar四个函数:
第一个printf:先把.LC0的立即值传入%rdi中,然后用call指令跳转到puts。
3.18函数对应汇编语句1
exit:将立即数1传入了%edi中,然后用call指令跳转到exit。
3.19函数对应汇编语句2
第二个printf:将三个参数传入参数寄存器中,然后用call指令跳转到printf。
3.20函数对应汇编语句3
sleep:将一个参数传到%edi中,之后用call指令跳转到sleep。
3.21函数对应汇编语句4
getchar:不需要参数,直接用call指令跳转即可。
3.22函数对应汇编语句5
3.4 本章小结
阐述了编译的概念和作用;说明分析了编译器是如何处理C语言的各个数据类型包括常量、变量,以及各种操作:如赋值、类型转换、算术操作、关系操作、数组操作、控制转移、函数操作等。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
汇编过程将上一步编译产生的由ASCII码构成的汇编代码转换为对应的01机器代码,从人能够理解的字符转变为计算机能看懂的二进制程序码的过程,这里的汇编是指从.s到.o即编译后的文件到生成机器语言二进制程序的过程。
汇编的作用:
编译器将高级语言编写的C语言代码翻译为汇编代码时,尽管结构组成更加简单,但计算机还是不能理解。通过汇编过程将汇编代码转变为二进制机器代码使得CPU可以理解,从而执行我们让它执行的各种操作。
4.2 在Ubuntu下汇编的命令
命令:gcc -c hello.s -o hello.o
4.1产生hello.o的命令
可以看见文件夹中多出了一个hello.o文件
4.2多出一个hello.o文件
4.3 可重定位目标elf格式
用指令获取hello.o的elf格式:
4.3获取hello.o的elf格式
文件夹中多了一个helloo.elf文件
4.4helloo.elf文件
打开helloo.elf,首先是ELF头,这个节存储了hello.o文件的一些基本信息:
4.5ELF头
接下来是节头,这张表中存储了elf表中每一个节的具体信息,包括名称,类型,地址,偏移量等。以此为索引,能够对elf文件中每一个具体的节进行访问。
4.6节头
然后是重定位节.rela.text,其包含.text节需要进行重定位的信息,在.o生成可执行文件的时候会被修改。
4.7.rela.text截图
然后是重定位节.rela.eh_frame,是.eh_frame节重定位信息。
4.8.rela.eh_frame截图
最后是.symtab节,其存放着程序中的所有符号变量。链接器可以通过这张表来获取当前可重定位目标文件中的符号信息,并以此来对文件进行链接。
4.9.symtab截图
4.4 hello.o的结果解析
用objdump反汇编得到的代码和hello.s对比如下
4.10对比截图
对比可知有以下几点不同:
分支跳转语句:
4.11分支跳转语句的对比
在hello.s中跳转语句的地址用.L2、.L3、.L4表示,而在反汇编代码中用具体地址来表示。
函数调用:
4.12函数调用对比
hello.s代码中的call语句后只跟着函数名,但反汇编代码中除了函数名还有函数具体地址。
全局变量访问:
4.13全局变量访问对比
反汇编之前,访问.rodata节使用段名称加%rip,反汇编之后是0x0(%rip),这是由于.rodata的数据地址是运行时确定的,故访问是要重定位的,所以汇编成机器语言时要设置操作全0并添加重定位条目。
机器代码和汇编语言中的立即数进制不同:
在.s文件中立即数为10进制;在.o文件当中,立即数都变为16进制。因为计算机是基于二进制运行的,十六进制可以很方便的与二进制相互转化,因此这里更换成了16进制。
4.5 本章小结
阐述了汇编的概念和作用,分析了hello.o转换为ELF格式文件的内容以及作用;另外分析对比了重定位前的汇编程序和重定位后反汇编的汇编程序。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接的概念:
链接是指在电子计算机程序的各模块之间传递参数和控制命令,并把它们组成一个可执行的整体的过程。
链接的作用:
地址和空间的分配,符号决议和重定位。符号决议:也可以说地址绑定,分动态链接和静态链接;重定位:假设此时又两个文件:A,B。A需要B中的某个函数mov的地址,未链接前将地址置为0,当A与B链接后修改目标地址,完成重定位。
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.1在Ubuntu下链接的命令
文件夹中多了hello的可执行文件。
5.2hello的可执行文件截图
5.3 可执行目标文件hello的格式
使用readelf -a hello > hello.elf命令生成可执行文件hello的ELF格式文件。
5.3hello.elf的截图
可执行文件的ELF头:
5.4可执行文件的ELF头
可执行文件的节头:
5.5可执行文件的节头1
5.6可执行文件的节头2
程序头:
5.7可执行文件的程序头
段节:
5.8可执行文件的段节
重定位节:
5.9可执行文件的重定位节
5.4 hello的虚拟地址空间
在“5.7可执行文件的程序头”的截图中可以看到,内存中从地址0x400040开始的一段区域是PHDR段,这一段主要用于保存程序头表;
INTERP段起始于0x400270,同样也是只读数据,其主要作用是指定在程序已经从可执行映射到内存之后,必须调用解释器。在这里解释器并不意味着二进制文件的内存必须由另一个程序解释。它指的是这样的一个程序:通过链接其他库,来满足未解决的引用。
LOAD段起始于0x400000表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串)、程序的目标代码等。
DYNAMIC段起始于0x403e50,保存了其他动态链接器(即,INTERP中指定的解释器)使用的信息。
NOTE段起始于0x40028c,保存了辅助信息。
GNU_STACK段起始于0x0权限标志,标志栈是否是可执行的。
GNU_RELRO段起始于0x403e50,指定在重定位结束之后那些内存区域是需要设置只读。
通过edb打开hello可执行程序,在Data Dump窗口中查看加载到虚拟地址中的hello程序,在0x400000~0x401000段中,程序被载入,自虚拟地址0x400000开始至0x400fff之间的每个节的排列与hello的elf格式文件顺序相同。
5.10hello在EDB下的Data Dump窗口
5.5 链接的重定位过程分析
5.11进行链接后的两个文件对比
进行链接之后两个文件的区别如上图,所有的相对地址被修改为了确定的运行时内存地址。而且hello的可执行目标文件中多出了.init段和.plt段,前者用于初始化程序执行环境,后者用于程序执行时的动态链接。
在执行这个链接过程之前,链接器已经通过可重定位目标文件中的符号表信息,将每个符号引用都与一处符号定义对应起来。汇编器生成的重定位条目指明了需要被修改的符号引用的位置,以及有关如何计算被引用修改的一些信息。
5.6 hello的执行流程
函数名称 | 函数地址 |
_init | 0x401000 |
.plt | 0x401020 |
puts@plt | 0x401030 |
pirntf@plt | 0x401040 |
getchar@plt | 0x401050 |
exit@plt | 0x401060 |
sleep@plt | 0x401070 |
_start | 0x401080 |
_dl_relocate_static_pie | 0x4010b0 |
main | 0x4010b1 |
__libc_csu_init | 0x401140 |
__libc_csu_fini | 0x4011a0 |
_fini | 0x4011a4 |
5.7 hello的动态链接分析
对于printf,getchar这样使用频繁的函数,如果每个程序链接时都要将这些代码链接进去的话,一份可执行目标文件就会有一份printf的代码,这是对内存的极大浪费。为了遏制这种浪费,系统会在可重定位目标文件链接时仅仅创建两个辅助用的数据结构,而直到程序被加载到内存中执行的时候,才会通过这些辅助的数据结构动态的将printf的代码重定位给程序执行。这种有趣的技术被称为延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。而那两个辅助的数据结构分别是过程链接表(PLT)和全局偏移量表(GOT),前者存放在代码段,后者存放在数据段。
用edb对hello的运行过程进行解析,可以看到在_dl_start与_dl_init之前,GOTPLT表的内容如图所示:
5.12GOTPLT表的内容(1)
这时的PLT表没有值,因为程序还没有执行动态链接。PLT是一个数组,PLT[0]跳转到动态链接器中,PLT[1]调用系统启动函数来初始化执行环境。直到PLT[2]开始的每个条目才是负责具体函数的链接的。
在执行完_dl_start后。GOT表中的数据发生了改变。
5.13GOTPLT表的内容(2)
GOT[1]指向0x7fd877827190;
GOT[2]指向0x7fd877812200;
GOT[1]指向的重定位表如下:
5.14GOT[1]指向的重定位表
GOT[2]指向的动态链接器如下:
5.15GOT[2]指向的动态链接器
当程序需要调用一个动态链接库内定义的函数时,call指令并没有让控制流直接跳转到对应的函数中去,取而代之的是,控制流会跳转到该函数对应的PLT表中,然后通过PLT表将当前将要调用的函数的序号压入栈中,下一步,调用动态链接器。
动态链接器会根据栈中的信息执行重定位,将真实的printf的运行时地址写入GOT表,取代了GOT原先用来跳转到PLT的地址,变为真正的函数地址。
于是,控制流找过来时,GOT给它指的路是动态链接器,动态链接器将真正的地址给GOT表。
下一次控制流再找上门来的时候,GOT就可以将真正的函数执行时地址传达过去,完成了动态链接的过程。
5.8 本章小结
阐述了链接的概念和作用,分析了hello可执行程序的ELF格式,以及其与hello.o的不同,重点分析了hello程序的虚拟地址空间、重定位和执行过程,简述了动态链接的原理。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体[5]。
6.2 简述壳Shell-bash的作用与处理流程
在计算机科学中,Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(命令解析器)。它类似于DOS下的command.com和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序[6]。
bash是shell的一种,在早年的UNIX年代,发展者众多,所以就有许多不同的版本,例如Bourne shell(sh),这也是必然的,每种shell都有其应用的需求,很难说孰好孰坏。而在Linux中默认的shell就是Bourne-Again shell(简称bash)。
shell的处理流程如下:
6.1shell的处理流程
6.3 hello的fork进程创建过程
用户在终端中输入./hello,shell首先判断这个参数不是shell内置的命令,于是把这个命令作为一个可执行程序的名字执行。
接下来shell会执行fork函数,一个进程调用fork函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己[8]。
6.4 hello的execve过程
在父进程fork后,父进程继续运行shell的程序,而子进程将通过execve加载用户输入的程序。由于hello是前台运行的,所以shell会显式地等待hello运行结束。
fork创建了一个新的进程,产生一个新的PID,execve用被执行的程序完全替换了调用进程的映像。execve启动一个新程序,替换原有进程,所以被执行进程的PID不会改变。
execve函数接受三个参数:
path:要执行的文件完整路径。
argv:传递给程序完成参数列表,包括argv[0],它一般是执行程序的名字,最后一个参数一般是NULL。
envp:是指向执行execed程序的环境指针,一般设为NULL。
6.5 hello的进程执行
操作系统为hello进程执行维持上下文。而上下文是内核重新启动一个被抢占的进程所需的状态,它由包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值组成。
hello进程在内存执行的过程中并不是一直占用着CPU的资源。当内核执行系统调用时会发生上下文切换:将当前的上下文信息保存在内核中,恢复某个先前被抢占的进程的上下文,将控制传递给这个新恢复的进程。
6.6 hello的异常与信号处理
在hello运行中会出现异常,这里通过linux系统的信号机制来使hello正常运行。
hello程序中的sleep函数会像进程本身发送一个STPSIG使其休眠一段时间。在程序中,这个时间是2.5秒。当请求的时间到了,或者sleep函数被一个信号中断,进程就会继续执行,继续调用printf函数。
当程序正常执行直到结束时,显示如下:
6.2程序正常执行
当程序运行到中途时按下ctrl+z,产生情况如下:
6.3程序运行中按下ctrl+z
ctrl+z给程序传入了一个SIGSTP信号,这个信号使程序暂时挂起。此时输入ps命令查看进程可以看到hello程序仍然在后台进程当中而没有中止:
6.3ps命令查看进程
此时如果输入命令fg,就能使hello程序继续执行:
6.4输入fg命令继续执行
当程序运行到中途时按下ctrl+c,就会给进程发送一个终止信号,输入命令jobs可以看到hello已经不在作业列表当中了:
6.5输入jobs指令
当程序运行到中途时乱按键盘,程序仍然会正常执行:
6.6程序运行途中乱按键盘
当程序运行到中途时将其停止,输入pstree,能够看到当前计算机正在执行的所有进程的关系:
6.7输入pstree指令
程序运行中使用kill杀死hello进程:
6.8输入kill指令
6.7本章小结
阐述了进程的概念与作用,介绍了shell的处理流程,调用fork创建新进程,调用execve执行hello,hello的进程执行,hello的异常与信号处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
机器语言指令中出现的内存地址都是逻辑地址,需要转换成线性地址,再经过MMU(内存管理单元)转换成物理地址才能够被访问到。
逻辑地址:包含在机器语言中用来指定一个操作数或一条指令的地址。每一个逻辑地址都由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离。
线性地址:Linux中逻辑地址等于线性地址。因为Linux所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从 0x0 开始,这样线性地址 = 逻辑地址 + 0x0,也就是说逻辑地址等于线性地址了。
虚拟地址:虚拟地址将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存。同时,它为每个进程提供了一致的地址空间,从而简化了内存管理。最后,它保护了每个进程的地址空间不被其他进程破坏。
物理地址:而物理地址则是对应于主存的真实地址,是能够用来直接在主存上进行寻址的地址。由于在系统运行时,主存被不同的进程不断使用,分区情况很复杂,所以如果要用物理地址直接访问的话,地址的处理会相当麻烦。
7.2 Intel逻辑地址到线性地址的变换-段式管理
机器语言指令中出现的内存地址,都是逻辑地址,需要转换成线性地址,再经过MMU(CPU中的内存管理单元)转换成物理地址才能够被访问到。 我们写个最简单的hello world程序,用gcc编译,再反编译后会看到以下指令:
mov 0x80495b0, %eax 这里的内存地址0x80495b0 就是一个逻辑地址,必须加上隐含的DS 数据段的基地址,才能构成线性地址。也就是说 0x80495b0 是当前任务的DS数据段内的偏移。
7.1逻辑地址转线性地址 在x86保护模式下,段的信息(段基线性地址、长度、权限等)即段描述符占8个字节,段信息无法直接存放在段寄存器中(段寄存器只有2字节)。Intel的设计是段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值(index)。 Linux中逻辑地址等于线性地址。为什么这么说呢?因为Linux所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从 0x00000000 开始,长度4G,这样 线性地址=逻辑地址+ 0x00000000,也就是说逻辑地址等于线性地址了。 这样的情况下Linux只用到了GDT,不论是用户任务还是内核任务,都没有用到LDT。GDT的第12和13项段描述符是 __KERNEL_CS 和__KERNEL_DS,第14和15项段描述符是 __USER_CS 和__USER_DS。内核任务使用__KERNEL_CS 和__KERNEL_DS,所有的用户任务共用__USER_CS 和__USER_DS,也就是说不需要给每个任务再单独分配段描述符。内核段描述符和用户段描述符虽然起始线性地址和长度都一样,但DPL(描述符特权级)是不一样的。__KERNEL_CS 和__KERNEL_DS 的DPL值为0(最高特权),__USER_CS 和__USER_DS的DPL值为3。 用gdb调试程序的时候,用info reg 显示当前寄存器的值:
cs 0x73 115
ss 0x7b 123
ds 0x7b 123
es 0x7b 123
可以看到ds值为0x7b, 转换成二进制为 00000000 01111011,TI字段值为0,表示使用GDT,GDT索引值为 01111,即十进制15,对应的就是GDT内的__USER_DATA 用户数据段描述符。 从上面可以看到,Linux在x86的分段机制上运行,却通过一个巧妙的方式绕开了分段。Linux主要以分页的方式实现内存管理[9]。
7.3 hello的线性地址到物理地址的变换-页式管理
分页是CPU提供的一种机制,Linux只是根据这种机制的规则,利用它实现了内存管理。
分页的基本原理是把线性地址分成固定长度的单元,称为页(page)。页内部连续的线性地址映射到连续的物理地址中。X86每页为4KB(为简化分析,我们不考虑扩展分页的情况)。为了能转换成物理地址,我们需要给CPU提供当前任务的线性地址转物理地址的查找表,即页表(page table),页表存放在内存中。 在保护模式下,控制寄存器CR0的最高位PG位控制着分页管理机制是否生效,如果PG=1,分页机制生效,需通过页表查找才能把线性地址转换物理地址。如果PG=0,则分页机制无效,线性地址就直接作为物理地址。 为了实现每个任务的平坦的虚拟内存和相互隔离,每个任务都有自己的页目录表和页表。 为了节约页表占用的内存空间,x86将线性地址通过页目录表和页表两级查找转换成物理地址。 32位的线性地址被分成3个部分: 最高10位 Directory 页目录表偏移量,中间10位 Table是页表偏移量,最低12位Offset是物理页内的字节偏移量。 页目录表的大小为4KB(刚好是一个页的大小),包含1024项,每个项4字节(32位),表项里存储的内容就是页表的物理地址(因为物理页地址4k字节对齐,物理地址低12位总是0,所以表项里的最低12字节记录了一些其他信息,这里做简化分析)。如果页目录表中的页表尚未分配,则物理地址填0。 页表的大小也是4k,同样包含1024项,每个项4字节,内容为最终物理页的物理内存起始地址。 7.2线性地址转物理地址
每个活动的任务,必须要先分配给它一个页目录表,并把页目录表的物理地址存入cr3寄存器。页表可以提前分配好,也可以在用到的时候再分配。 还是以 mov 0x80495b0, %eax 中的地址为例分析一下线性地址转物理地址的过程。 前面说到Linux中逻辑地址等于线性地址,那么我们要转换的线性地址就是0x80495b0。转换的过程是由CPU自动完成的,Linux所要做的就是准备好转换所需的页目录表和页表(假设已经准备好,给页目录表和页表分配物理内存的过程很复杂,后文再分析)。 内核先将当前任务的页目录表的物理地址填入cr3寄存器。 线性地址 0x80495b0 转换成二进制后是 0000 1000 0000 0100 1001 0101 1011 0000,最高10位0000 1000 00的十进制是32,CPU查看页目录表第32项,里面存放的是页表的物理地址。线性地址中间10位00 0100 1001 的十进制是73,页表的第73项存储的是最终物理页的物理起始地址。物理页基地址加上线性地址中最低12位的偏移量,CPU就找到了线性地址最终对应的物理内存单元。 我们知道Linux中用户进程线性地址能寻址的范围是0 - 3G,那么是不是需要提前先把这3G虚拟内存的页表都建立好呢?一般情况下,物理内存是远远小于3G的,加上同时有很多进程都在运行,根本无法给每个进程提前建立3G的线性地址页表。Linux利用CPU的一个机制解决了这个问题。进程创建后我们可以给页目录表的表项值都填0,CPU在查找页表时,如果表项的内容为0,则会引发一个缺页异常,进程暂停执行,Linux内核这时候可以通过一系列复杂的算法给分配一个物理页,并把物理页的地址填入表项中,进程再恢复执行。当然进程在这个过程中是被蒙蔽的,它自己的感觉还是正常访问到了物理内存。 怎样防止进程访问不属于自己的线性地址(如内核空间)或无效的地址呢?内核里记录着每个进程能访问的线性地址范围(进程的vm_area_struct 线性区链表和红黑树里存放着),在引发缺页异常的时候,如果内核检查到引发缺页的线性地址不在进程的线性地址范围内,就发出SIGSEGV信号,进程结束,我们将看到程序员最讨厌看到的Segmentation fault[9]。
7.4 TLB与四级页表支持下的VA到PA的变换
建立VA与PA的对应关系:
MMU有段描述符(1M)(还有页描述符大页(64KB)小页(4KB)和极小页(1KB))
,我们这里说段页表的建立。比如32位CPU,4G的寻址空间可分为4094个段(4G/1MB),所以可以建立4096个对应的关系而实际的内存肯定没到4G(VA-PA 可多对一)所以首先要在内存中指定存放该对应表的其实位置(可通过CP15协处理器指定)。
VA到PA转化过程[10]:
7.3VA到PA转化
如果TLB中没有命中,MMU向页表中查询,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并且向TLB中添加条目。
如果查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
7.5 三级Cache支持下的物理内存访问
根据物理地址VA,使用CI进行组索引,每组8路,对8路的块分别匹配CT,如果匹配成功而且块的标志位valid为1,则命中,根据CO取出数据返回,如果没能匹配成功或者标志位valid是0,则不命中,后者为冷不命中,前者为冲突不命中。要向下一层缓存中查询数据,如果映射到的组内有空闲块,优先选择空闲块放置,否则若组内都是有效块,则产生冲突,采用LFU策略进行替换。
7.4cache的三级缓存
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用的时候,内核会为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本,它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
虚拟内存的机制使得fork函数可以快速的运行,因为当我们fork了一个新进程的时候,系统事实上并没有将原进程的整个上下文复制一遍,它仅仅只是创建了份一模一样的描述地址空间的数据结构,然后将这个数据结构给予子进程。当子进程执行只读代码时,它与父进程实际上共用了物理内存中的同一片区域的内容。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新进程。因此,通过虚拟内存这种巧妙的机制为每个进程都保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
通过execve函数在当前进程中加载并运行包含之可执行目标文件中的程序,用a.out程序有效地替代了当前程序。这个过程有以下几个步骤:
删除已存在的用户区域:
删除当前进程虚拟地址的用户部分中已存在的区域结构。
映射私有区域:
为新程序的代码、数据、.bss和栈区域创建新的区域结构。所有这些新的区域都是私有的,写时复制的。代码和数据区域被映射为a.out文件中的.test和.data区。bss区域是请求二进制0的,映射到匿名文件,其大小包含在a.out当中。栈和堆区域也是请求二进制零的,初始长度为零。下图概括了私有区域的不同映射:
7.5私有区域的不同映射
映射共享区域:
如果a.out程序与共享对象或目标链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器:
execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障是一种常见的故障,当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。
需要将控制传递给处理程序,运行故障处理程序后,处理程序要么重新执行当前指令,要么终止,这就是故障处理流程。
缺页中断就是要访问的页不在主存,需要操作系统将其调入主存后再进行访问。在这个时候,被内存映射的文件实际上成了一个分页交换文件[10]。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
显示分配器,要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显示分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。
隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如,诸如Lisp、ML、以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
带边界标签的隐式空闲链表分配器原理:
一个块是由一个字的头部、有效载荷、可能的一些额外的填充,以及在块的结尾处的一个字的脚部组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。
我们将对组织为一个连续的已分配块和空闲块的序列,这种结构称为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意:此时我们需要某种特殊标记的结束块,可以是一个设置了已分配位而大小为零的终止头部。
Knuth提出了一种边界标记技术,允许在常数时间内进行对前面快的合并。这种思想是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。
它也存在一个潜在的缺陷,它要求每个块都保持一个头部和一个脚部,在应用程序操作许多个小块时,会产生显著的内存开销。例如,如果一个图形应用通过反复调用malloc和free来动态地创建和销毁图形节点,并且每个图形节点都只要求两个内存字,那么头部和脚部将占用每个已分配块的一半的空间。
显式空闲链表分配器原理:
因为根据定义,程序不需要一个空闲块的主体,所以实现空闲链表数据结构的指针可以存放在这些空闲块的主体里面。
显式空闲链表结构将堆组织成一个双向空闲链表,在每个空闲块的主体中,都包含一个pred(前驱)和succ(后继)指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。
7.10本章小结
阐述了hello的存储器地址空间、intel的段式管理、hello的页式管理,TLB与四级页表支持下的VA到PA的变换、三级cache下的物理内存访问,还介绍了hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理原理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个字节的序列,所有的I/O设备(例如网络,磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix I/O接口:
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
改变当前的文件位置。对于每个打开的文件内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会出发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处没有明确的“EOF符号”。
类似的,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix函数:
int open(char *filename, int flags, mode_t mode):open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件;
O_RDONLY:只读;
O_WRONLY:只写;
O_RDWR:读可写;
O_CREAT:如果文件不存在,就创建它的一个截断的文件;
O_TRUNC:如果文件已存在,就截断它;
O_APPEND:在每次写操作前,设置文件位置到文件的结尾处;
int close(int fd):关闭一个打开的文件;
ssize_t read(int fd, const void *buf, size_t n):从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示实际传送的字节数量;
ssize_t write(int fd, const void *buf, size_t n):从内存位置buf复制最多n个字节到描述符fd的当前文件位置。
8.3 printf的实现分析
研究printf的实现,首先来看看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; }
首先arg获得第二个不定长参数,即输出的时候格式化串对应的值。
查看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结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf, i)将长度为i的buf输出。write函数如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall,查看syscall的实现:
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
syscall将字符串中的字节“hello 1180300210 李卓君”从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。于是我们的打印字符串“hello 1180300210 李卓君”就显示在了屏幕上。
8.4 getchar的实现分析
getchar()是stdio.h中的库函数,它的作用是从stdin流中读入一个字符,也就是说,如果stdin有数据的话不用输入它就可以直接读取了,第一次getchar()时,确实需要人工的输入,但是如果你输了多个字符,以后的getchar()再执行时就会直接从缓冲区中读取了。 实际上是 输入设备->内存缓冲区->程序getchar。 getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回[11]。
8.5本章小结
本章主要介绍了Linux的I/O设备管理方法、Unix I/O接口及其函数,分析了printf函数和getchar函数。
(第8章1分)
结论
通过对hello从程序到进程,从开始到停止的经历,我们了解到一个程序在系统中运行的大致过程以及在这个过程中可能遇到的问题。将一个程序的运行划分为各个阶段,包括预处理、编译、汇编、链接等,深入探讨了在各个阶段中产生的各类信息及其作用,包括各类常量变量的存放位置,对于函数、数组、表达式的处理。接下来进入运行阶段,描述了一个进程是如何产生的,以及进程管理,对于异常的处理,深入计算机存储系统底层,描述CPU并发地运行hello进程时是如何根据请求在内存中寻找信息并传送到指定位置的。最后描述了hello的I/O管理,展现了hello是如何通过printf将语句打印到屏幕上的。
我们通过对于hello各个阶段的分析将课本上学习到的各项重点内容应用到实际过程中,可以说,hello作为一个实例向我们展现了课本上各章的知识并将它们串联起来使我们能够从宏观上把握理解CSapp这门课程的知识脉络。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello.c | hello的C源代码 |
hello.i | hello.c预编译产生的.i文件 |
hello.s | hello.i编译后产生的hello.s汇编代码文件 |
hello.o | hello.s汇编后产生的hello.o可重定位目标文件 |
helloo.elf | hello.o的ELF格式 |
hello.elf | hello的ELF格式 |
hello.objdump | hello经objdump的反汇编代码 |
helloo.objdump | hello.o经objdump的反汇编代码 |
hello.txt | hello经objdump -dxg的反汇编代码 |
Hello | hello.o链接之后生成的可执行目标文件 |
(附件0分,缺失 -1分)
参考文献
[1] 进程和程序的区别.https://blog.csdn.net/shida_hu/article/details/79401110
[2] 深入理解计算机系统(原书第3版)/(美)兰德尔·E.布莱恩特等著;龚奕利,贺莲译。—北京:机械工业出版社,2016.7
[3] 预处理.百度百科.https://baike.baidu.com/item/%E9%A2%84%E5%A4%84%E7%90%86/7833652?fr=aladdin
[4] 编译.百度百科https://baike.baidu.com/item/%E7%BC%96%E8%AF%91/1258343?fr=aladdin
[5] 进程.百度百科https://baike.baidu.com/item/%E8%BF%9B%E7%A8%8B/382503?fr=aladdin
[6] shell.百度百科. https://baike.baidu.com/item/shell/99702?fr=aladdin
[7] 简书.咋家.什么是shell和bash.https://www.jianshu.com/p/a702a01db5c7
[8] 操作系统中的fork()函数对应的进程创建过程. alice4. https://www.cnblogs.com/Wangjiaq/p/9815822.html
[9] Linux下逻辑地址、线性地址、物理地址详细总结. AlanTu. https://www.cnblogs.com/alantu2018/p/9002441.html
[10] 缺页中断.百度百科.https://baike.baidu.com/item/%E7%BC%BA%E9%A1%B5%E4%B8%AD%E6%96%AD/5029040?fr=aladdin
[11] C语言中getchar()和putchar()的实现细节. 紫荆的传说. https://blog.csdn.net/happyforever91/article/details/51713741
(参考文献0分,缺失 -1分)