Hello是每个程序员最先接触到的程序,大部分IDE是直接默认生成的,程序员需要做的只需要按两下鼠标,完成编译,就可以运行了。屏幕上出现hello。。。
但实际上的过程不能用轻松两个字来描述。Hello.c是用高级语言C编写的,我们要经过预处理,编译,汇编等过程,才能作为机器能读懂的机器代码储存在磁盘中。Hello现在的状态叫程序(Program),用户通过shell,调用一系列函数将hello运行在内存中。他是通过一种叫做进程(Process)的抽象来实现的。
Execve函数将hello加载至内存,顺着逻辑控制流,hello在硬件中驰骋,最终出现在屏幕上。最终程序终止,shell将子进程回收。尘归尘,土归土,一切复原。
硬件环境:Intel Core i7-6700HQ x64CPU,8G RAM,256G SSD
软件环境:VMware Workstation Ubuntu18.04.1 LTS
开发调试工具:vim,gcc,as,ld,edb,readelf,hexedit
本章主要简单介绍了 hello 的 p2p,020 过程,列出了本次实验信息:环境、中间结果。
概念:预处理(或称预编译)是指在进行编译的第一遍扫描(词法扫描和语法分析)之前所作的工作。
预处理器(cpp)根据以字符#开头的命令(宏定义(#define)、文件包含(#include)、条件编译(#ifdef)),修改原始的C程序。比如将头文件从库中提取出来,然后插入到程序文本中,得到一个完整的源程序,通常以.i作为文件扩展名。
注意:宏定义的用法有许多,许多函数可以考虑通过宏定义的方式来实现,效率更高。条件编译和文件包含也有许多用法,自己拓展。
https://www.cnblogs.com/clover-toeic/p/3851102.html
一些常用的预处理命令
命令:cpp hello.c > hello.i
gcc -E test.c -o test.i
Cpp命令处理后的hello.c文件得到hello.i文件,可以看到hello.i也是一个文本文件,用gedit打开之后看到原先一二十行的代码被扩展到3118行增加了许多内容。
用vim打开hello.i,发现main函数在文本的最末端
但是程序之前的#include
以stdlib.h为例,cpp是如何将头文件展开的呢?
先看看头文件的组成:
Cpp到默认的环境变量下寻找stdlib.h,打开/usr/include/stdlib.h,其中可能仍然会有#define语句,cpp对此进行递归展开,最终hello.i文件中只有对外部变量的声明,函数声明,没有宏定义。
本章介绍了hello.c程序在编译之前需要做的准备工作,我们每次在写main函数之前在文件头添加的头文件和宏定义其实都需要经过比较复杂的处理。简单的一个hello程序,也是有很长的代码去实现的,只不过前人帮我们做好的准备工作。
概念:指将预处理后的程序转化成特定的汇编程序的过程。编译器将我们用高级语言C写的程序按照一定的语法规则分方法翻译成汇编代码,汇编语言一般包括mov赋值指令,jg条件跳转,comjg条件转移,逻辑运算指令等等。在用编译器编译C程序的时候可以指定编译器的优化等级,不同的优化等级对程序等处理不同,优化等级越高,程序越符合机器的思维方式,能够最大化利用CPU,但是不利于人的理解。
编译器的输入时预处理后的.i文件,输出是.s文件。
值得注意的是我们在学习CSAPP中做各种实验用到了一种很强的的反汇编工具——objdump,他可以将可执行文件(二进制文件)反汇编,得到汇编代码,但是两者的格式还是不尽相同,注意区别。
编译一般分为三个步骤:
1、 词法分析:编译器将程序中的字符串分离出来,转化成内部标准的表示结构。
2、 语法分析:将先前词法分析达到的token生成一个抽象语法树。
3、 优化和目标代码生成;编译器的后端会负责对代码进行优化,比如公共子式提取,循环优化,删除无用代码,得到目标代码。
编译过程涉及到许多复杂的概念以及相关知识,有兴趣可以去提前看看编译原理。
3.3.0 汇编指令
编译后的得到的hello.s文件也是文本文件,但是内容比hello.i小了很多。
查看hello.i,在main函数之前多了一段程序头:
注意汇编程序由三个不同的元素组成:
指示(Directives) 以点号开始,用来指示对编译器,连接器,调试器有用的结构信息。指示本身不是汇编指令。例如,.file 只是记录原始源文件名。.data表示数据段(section)的开始地址, 而 .text 表示实际程序代码的起始。.string 表示数据段中的字符串常量。 .globl main指明标签main是一个可以在其它模块的代码中被访问的全局符号 。至于其它的指示你可以忽略。
标签(Labels) 以冒号结尾,用来把标签名和标签出现的位置关联起来。例如,标签.LC0:表示紧接着的字符串的名称是 .LC0. 标签main:表示指令 pushq %rbp是main函数的第一个指令。按照惯例, 以点号开始的标签都是编译器生成的临时局部标签,其它标签则是用户可见的函数和全局变量名称。
指令(Instructions) 实际的汇编代码 (pushq %rbp), 一般都会缩进,以便和指示及标签区分开来。
(参考:https://blog.csdn.net/pro_technician/article/details/78173777)
3.3.1数据
hello.c文件中涉及到的C数据类型有整型,数组,字符串。下面具体分析。
一、 字符串
程序中的字符串分别是:
1)“Usage: Hello 学号 姓名!\n”,printf传入的格式化参数。在hello.s中声明如下图3.3,注意到字符串使用UTF-8的格式编码的,一个汉字在UTF-8中占三个字节。
2)“Hello %s %s\n”,仍然是由printf函数传入的格式化参数,hello.s声明如下。
可以看到,两个字符串都被存放在.rodata段,作为全局变量。(和书上说的完全一样)
二、 整型
程序中设计的整型数有四处:
1)int sleepsec :sleepsec在C程序中被声明成为全局变量,并且已经被赋值。编译器处理时,在.data段声明该变量,.data节存放已经初始化的全局和静态C变量。图3.4中,编译器先在.text段中声明sleepsec为全局变量,之后在.data段设置对齐方式为4字节,,类型为对象,大小为四字节,设置为long类型其值为2。(可能是编译器偏好,linux下long和int大小相同)
2)int i :编译器将局部变量存放在栈和寄存器中,hello.s程序中i存放在-4(%rbp),占据4字节。
3)int argv :记录传入参数个数
4)立即数:C程序中有许多直接给出的比如3,0,10等,在汇编中直接以立即数形式给出。
三、数组
hello.c中涉及数组只有一个char argv[],函数执行的命令行参数。
argv是一个字符串数组,作为第二个参数传入。Linux中所有指针类型包括char都为8字节大小,栈中一般为char*[]分配了一段连续的空间来存放相关内容,并且数组一般都是从栈底指针开始分配。函数传参的时候按逆序将参数压入栈中,这样取参数就是正序(只需要将rsp指针上移即可),如图3.5。
3.3.2赋值
程序中涉及的赋值操作有两处:
1) int sleepsec=2.5 注意这里涉及到类型转换,将浮点数转换成整型,如图3.4所示,编译器将2赋给sleepsec,并且调整为long类型。
2) 对循环变量i的赋值:直接立即数赋值。Mov指令:
3.3.3类型转换
对int sleepsec的赋值中涉及到隐式类型转换。
实际上类型转换是不改变值在内存中的存储内容,而是按照不同的读取原则对存储内容进行位截断,位扩展等。一般来说,浮点型的默认类型是double,向整型转换遵循向零舍入的原则。
3.3.4 算术操作
程序中涉及到的算术操作有:
3.3.5关系操作
机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值。Cpu维护着一组单个位的条件码,用来描述最近的算术操作或逻辑操作。图3.7.1中所有的操作都会相应设置条件码,除此以外还有两种指令CMP和TEST指令,只设置条件码而不改变寄存器的值,汇编中条件转移和条件传送也是以此为基础实现的。
程序中涉及的关系运算:
3.3.6控制转移
关系运算是控制转移的基础。
程序中涉及到控制转移:
1)判断argc和3的大小,cmpl $3, -20(%rbp),栈中存放的是参数argc,cmpl指令会将argc-3所得到的结果用条件码描述,设置ZF标志位,如果相减结果得0,ZF会被设置,je指令根据ZF标志位确定是否需要跳转到L2还是顺序执行下面的指令。
汇编语言是高级语言和机器语言的中介,一方面具有可读性,但是不像高级语言那样易懂,但是一方面反映了机器的一些特征,汇编语言一定程度上翻译了指令集体系的架构。
但是从高级语言到汇编语言的映射转化是不容易的。
Ccl(编译器)将hello.i转换成hello.s文件。
概念:汇编器(as)将.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件.o中(把汇编语言翻译成机器语言的过程)。注意区别的是得到的目标文件(a.out)是二进制文件,并且需要链接器(ld)链接生成可执行文件之后才可以执行。
命令:as hello.s -o hello.o
使用 readelf -a hello.o > helloo.elf 指令获得 hello.o 文件的 ELF 格式。其组成如下:
i. Elf 头:以一个16字节序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,剩下的信息帮助链接器语法分析和解释目标文件的信息。包括ELF头大小,目标文件类型,机器类型,节头部表的文件偏移以及节头部表中条目的大小和数量。
ii. 节头部表描述了不同节的类型,位置以及大小。
(参考:http://blog.chinaunix.net/uid-790666-id-2547799.html)
iii. .rela.text重定位节,描述了.text节中需要重定位的信息,即当链接器链接该目标文件时需要修改的信息。图4.4中有8个重定位信息分别是对.L0(第一个字符串内容),puts函数,exit函数,.L1(第二个字符串内容),printf函数,sleepsec,sleep函数,getchar函数进行重定位声明。
下面详细分析一下ld链接器如何利用重定位表实现重定位:
上图是重定位信息的结构体。
注:重定位条目中信息(Info)包括symbol和type两部分,symbol占前4字节,type占后4字节。Symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位类型。
(addend的大小据我推测是根据指令的不同而不同的固定值,考虑第四章中Y86的指令集体系结构,可以知道call指令后面有8字节的地址长度,故addend=4.)
以.L1为例,链接器根据Info信息向.symtab节中查询链接目标的符号,由info.symbol=0x05,可以发现重定位的内容在.rodata节中的.L0,设重定位条目为r,则由重定位条目可知:
r.offset=0x18, r.symbol=.rodata, r.type=R_X86_64_PC32,r.addend=4
重定位使用PC相对寻址方式,计算方法如下:
可以利用hexedit查看机器字节的重定位信息:
其他重定位计算方法类似。
3).rela.eh_frame eh_frame 的重定位信息
4).symtab 用于存放在程序中定义和引用的函数和全局变量信息的一张符号表,重定位中的符号类型全在该符号表中有声明。
使用命令objdump -d -r hello.o > helloobj.txt获得反汇编代码,与hello.s中的汇编代码比较,发现如下差别:
本章讲述了从hello.s到hello.o的过程,这中间需要经过汇编器(as)的帮忙,生成的文件是用机器语言写的二进制文件,我们可以利用hexedit工具方便的查看和修改。当然阅读机器代码确实反人类,我们可以利用objdump(强大的反汇编工具)从另一个角度看看hello.o。
从汇编到达机器语言的过程也是艰辛但伟大的。
概念:链接是将各种代码个数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。而这对于开发和维护大型的程序具有很重要的意义。
命令: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-L/usr/lib/gcc/x86_64-linux-gnu/7 -L
/usr/lib/x86_64-linux-gnu -L/usr/lib -L/lib/x86_64-linux-gnu -L/lib/…/lib 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
(注:这个链接命令不是特别好找,真艰难)
命令还可以简化为:
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
/usr/lib/gcc/x86_64-linux-gnu/7/crtend.o
/usr/lib/x86_64-linux-gnu/crtn.o hello.o -lc -z relro -o hello
用objdump工具分别反汇编hello(已连接的hello),hello.o(未连接的hello),crti.o(动态库函数),如图:
从上面三张图可以看出经过链接后确实将不同的函数链接起来的(crti.o出现在hello中),也看出了可重定位目标文件和可执行文件之间的区别。
如果想从hello.c之间生成hello,可以用gcc命令:gcc -m64 -no-pie -fno-PIC hello.c -o hello
使用readelf -a hello >hello.elf生成hello的ELF格式文件。
同图4.2比较发现确实有区别,首先是程序头大小改变,节头的数量也增加了。而且也没有了.rel节,因为可执行目标文件已经完成重定位了。
可执行文件hello的ELF头中标明了入口点地址——程序运行执行的第一条指令的地址。
在Section header中对hello中对所有的节信息进行了声明,其中包括size大小和在程序中的偏移量offset,因为是已链接的程序,所以根据标出的信息就可以确定程序实际被加载到虚拟地址的地址。
查看ELF文件中的PHT(Program Header Table)程序头,这是在程序被执行的时候告诉链接器运行时需要加载的内容并提供动态链接信息。每个表项提供了各段在虚拟地址空间中的大小,位置,访问权限和对齐方式。Hello的程序头表中包含8个表项
可执行文件的ELF文件设计的很容易被加载的内存,其中连续的片被映射到连续的内存段。程序头节部表描述了这种映射关系。从程序头表中我们可以看到hello被初始化成两个内存段(LOAD),从图5.4中可以看出这两个LOAD段的权限不同:
第一个有R/E(读/执行)权限,开始于内存0x400000处,总共大小是0x81c字节,这其中包括ELF头,程序头部表,以及.init、.text和.rodata节。
第二个有RW(读写)权限,开始于内存0x600e00处,总共大小为0x254字节,占内存0x258字节(注意这里多的字节对应于运行时被初始化为0的.bss节)。
使用Edb打开hello程序,通过Data Dump窗口可以查看加载到虚拟地址中的hello程序。
在0x400000-0x401000段中,程序被载入,自虚拟地址0x400000开始,一直到0x40720结束,这其中每一个节的地址和图5.3中节头部表的Address声明的一样。
也可以看loaded symbol 很清晰。
用objdump工具分别反汇编hello(已连接的hello),hello.o(未连接的hello),crti.o(动态库函数),如图:
从上面三张图可以看出经过链接后确实将不同的函数链接起来的(crti.o出现在hello中),也看出了可重定位目标文件和可执行文件之间的区别。
详细分析hello与hello.o的反汇编文件区别:
hello > hellobj hello.o > helloobj
从这个表中可以看出表中确实存放的是main函数中调用的一些共享库中的函数。尤其是.plt,即PLT[0],其中的命令是对GOT[0],GOT[1]的操作,即将动态链接器地址压栈,跳转到重定位表。注意PLT中每个条目是16字节,GOT表中每个条目8字节。正好符合。
2) 函数调用:hello.o中函数调用都是main加上相对偏移,hello中直接使用的是函数的虚拟地址。
3) Hello中的地址全部换成了虚拟地址,参考ELF文件中的段表。
4) Hello.o中的重定位条目在链接后不再出现在hello中。
链接过程:
我们在链接中使用的命令,指定了动态链接器为64的 /lib64/ld-linux-x86-64.so.2,同时添加了crti.o,crt1.o,crtn.o等系统目标文件,将程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中的sleep,printf,exit,getchar等函数以及_start函数中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将这些函数从不同文件中链接生成一个可执行文件。
同时链接器根据可重定位目标文件中的重定位表同符号表一一对应,修改重定位信息,具体重定位算法在第四章汇编中介绍过,不再赘述。但需要强调的是在共享库中的函数,由于库的地址是随机加载的,在运行前无法知道其确切地址,只有通过动态链接延迟绑定机制实现。
使用edb单步调试运行程序,观察其调用的函数(一直stepover执行,未进入到每个函数中,那样太慢,所以函数比较少)。
可以利用edb中的functionfinder插件查找到指定地址区域中的函数,很方便。
对于动态共享链接库中 PIC 函数,编译器没有办法预测函数的运行时地址,所 以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代 码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表 PLT+全局偏移量 表 GOT 实现函数的动态链接,GOT 中存放函数目标地址,PLT 使用 GOT 中地址 跳转到目标函数。下面是程序在run前后的(即执行dl_init函数前后)的GOT表变化情况,注意到0x601000(为什么是这个地址可以参考图5.6,里面标出了GOT表的起始地址)处被修改了。
这一章主要介绍的是如何从可重定位文件hello.o生成可执行文件hello的过程。链接的知识网上介绍的都不多,但是非常重要。通过这一章我们知道了我们写出的hello程序可不是简简单单的一个main函数就解决了,简单的helloworld出现在屏幕上是需要经过许多函数库中函数的密切合作的。
概念:进程是计算机科学中最深刻最成功的概念之一。上一节中我们介绍了可执行文件的生成,将可执行文件的内容加载到内存中执行的过程叫做进程。简单的说,进程是程序的一个实例。,系统中每一个进程都运行与某进程的上下文中,这种认识是很重要的。进程的概念给了程序执行的关键抽象:
Shell:shell是一个交互型的应用级程序,它代表用户运行其他程序,是用户和系统内核沟通的桥梁。用户可以通过shell向操作系统发出请求,操作系统选择执行命令。
处理流程:
首先在终端中键入命令./hello 1170300909 武磊,shell通过上述流程处理该命令,判断这不是内置命令,会认定这是当前目录文件下的可执行目标文件hello,之后终端会调用fork函数创建一个新的子进程,新的子进程几乎但不完全和父进程相同,子进程可以得到父进程用户级虚拟地址空间相同(但独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈,子进程还获得与父进程任何打开文件描述符相同的副本,意味着子进程可以读写父进程中打开的任何文件。他们最大的不同是拥有不同PID。
父进程和子进程是并发运行的独立进程,内核可以任意方式交替执行他们的逻辑控制流中的指令,所以这会导致我们不能简单的凭直觉判断指令执行的顺序。
父进程会默认等待子进程执行完之后回收子进程,但是也会有产生僵死进程的情况,父进程可以调用waitpid函数等待其子进程终止或停止。
execve 函数在当前进程的上下文中加载并运行一个新程序。注意和fork的区别,他并未创建一个新的进程,而是覆盖了原先利用fork函数创建的进程。
注意execve函数只有在调用失败之后才会返回,所以与fork函数调用一次返回两次不同,execve函数调用一次从不返回。
当在命令行中输入./hello 1170300909 武磊命令时,如上文所述,shell会断命令解析判断,shell认为hello是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器的操作系统代码来运行它。任何linux程序都可以通过调用execve函数来调用加载器,加载器将可执行目标文件的代码和数据从磁盘中复制到内存,然后跳转到entrypoint来运行程序。
但是这里还是有细节需要推敲,实际上execve在加载了filename之后会调用加载器,加载器设置栈(设置栈的含义是删除子进程现有的虚拟内存段,创建一组新的代码,数据,堆栈,虚拟内存的机制简化了加载的过程,使得加载过程不需要实际从磁盘复制任何数据到内存,只需要将为原子程序的代码和数据段重新分配虚拟页,标记为无效,页表中的地址指向可执行文件,这样只有在cpu实际取指令后才会由MMU完成页的调入),后将控制传递给了新程序的主函数(hello),hello主函数的形式如下:
需要execve函数将环境变量数组以及参数数组传递给main,其栈帧结构如图:
用户模式和内核模式:shell提供了一个平台供用户向操作系统提出请求,操作系统看心情满足用户,但是这种交互在一定程度上是危险的,因为它提供了用户修改内核的机会,所以shell必须要设置一些防护措施来保护内核,限制指令的类型和可以作用的范围。处理器通常会在某个控制寄存器中设置一个模式位用来描述当前进程所拥有的特权,当设置了模式位后,进程处于内核模式(超级用户模式),该进程可以执行指令集中任何指令,同时也能访问系统中任何内存位置。
上下文切换:上下文切换是一种比较高层次的异常控制流。之前说过进程的抽象赋予了程序独立连续的逻辑控制流和地址空间,但实际上无论内存还是CPU资源都是多个进程之间共享的,所以操作系统必须合理的将这块蛋糕分好,即意味着要合理分配这些资源保证每个进程都能利用到。为了达到这个目的,内核为每一个进程维护了一个上下文信息,上下文就是内核重新启动一个被抢占的进程所需要的状态信息,由一组对象的值组成,包括通用目的寄存器,浮点寄存器,程序计数器,用户栈,状态寄存器,内核栈和各种内核数据结构,比如描述地址空间的页表,包含当前进程有关信息的进程表,以及包含进程以打开文件的信息的文件表。上下文切换的过程:
1、程序正常执行结果:连续输出10次,等待读取字符。
2、程序执行过程crtl+C:通过ps指令可以看出后台没有hello程序,说明crtl+c指令终止了hello。
3、程序执行过程crtl+z:通过ps和jobs指令都可以看到后台存在被挂起的hello程序,可以通过fg命令将其调成前台继续执行,说明crtl+z是停止(挂起)前台作业。
4、在程序执行过程中乱按:可以看出在程序执行过程中乱按实际上是将屏幕输入缓存到stdin,当getchar读到\n字符时,其他字符当做命令输入。
5、使用kill命令杀死程序hello
本章介绍了hello程序是如何在我们的计算机中运行的。Hello是以进程的形式运行,每个进程都处在某个进程的上下文中,每个进程也都有属于自己的上下文,用于操作系统进行进程调度。用户通过shell和操作系统交互,向内核提出请求,shell通过fork函数和execve函数来运行可执行文件。操作系统中有一套异常控制的系统,用于保障程序运行。异常的种类分为较低级的中断,终止,陷阱和故障,还有较高级的上下文切换和信号机制。
物理地址:加载到内存地址寄存器中的地址,内存单元的真正地址。在前端总线上传输的内存地址都是物理内存地址,编号从0开始一直到可用物理内存的最高端。这些数字被北桥(Nortbridge chip)映射到实际的内存条上。物理地址是明确的、最终用在总线上的编号,不必转换,不必分页,也没有特权级检查(no translation, no paging, no privilege checks)。
逻辑地址:CPU所生成的地址。逻辑地址是内部和编程使用的、并不唯一。例如,你在进行C语言指针编程中,可以读取指针变量本身值(&操作),实际上这个值就是逻辑地址,它是相对于你当前进程数据段的地址(偏移地址),不和绝对物理地址相干。我们反汇编得到的地址也是逻辑地址。使用逻辑地址的目的是为了防病程序员的抽象,可以将程序中所有部分抽象成一块连续额内存区域,但实际上现代计算机上许多程序都是分块的被映射到实际内存分片不连续的内存块上。其组成为16位段寄存器+32位有效地址,在实模式下,段选择器中存储的就是段描述符,在保护模式下,其中存放的是段选择符,作为描述符的索引,需要到GDT(全局描述符表或本地描述附表下寻找)
线性地址:是逻辑地址向物理地址转化的过程中必须要经过的一步,逻辑地址是在分段机制之前的地址,线性地址是分页机制之前的地址。
Segmentation的内存管理方式可以支持这种思路。逻辑地址空间由一组段组成。每个段都有名字和长度。地址指定了段名称和段内偏移。因此用户通过两个量来指定地址:段名称和偏移。段是编号的,通过段号而非段名称来引用。因此逻辑地址由有序对构成:
段偏移d因该在0和段界限之间,如果合法,那么就与基地址相加而得到所需字节在物理内存中的地址。因此段表是一组基地址和界限寄存器对。
以hello为例,在保护模式下,逻辑地址48位,线性地址32位,物理地址32位。48位逻辑地址分为16位段寄存器+32位有效地址,有效地址就是汇编中8(%ebp,%edx,4),也是段内偏移地址。段寄存器的结构如下:(段寄存器16bit是指程序员可见)
段寄存器的最低两位(RPL)描述了特权等级,TI是用于选择描述符表(0为GDT,1为LDT)。前13位作为段选择符,不是段描述符,段描述符大小8字节,结构如下:
段描述符中64bit,其中32位作为段基址,剩下32位描述了段信息,包括limit(段界限,显示段大小),DPL(特权等级:环保护模式,11为内核态,00为用户模式)。
但是其实段式管理是比较复杂的,有实模式和保护模式的区别,还涉及到8086处理器的历史。总的来说,段式管理能够很方便的将程序分成各个不同的段,设置相应的执行权限,同时还能方便的通过相对关系寻址。但是也是有缺陷的。
Linux下线性地址到物理地址的转换是通过分页机制实现的。内核为每一个进程维护了单独的任务结构(task_struct),其中包括了指向内核运行该进程所需要的所有信息(PID,指向用户栈指针,可执行目标文件,程序计数器),其中我们关心的是一个mm条目,指向mm_struct,他描述了当前进程的虚拟内存状态,其中pgd指向第一级页表基址,mmap指向一个vm_area_structs(区域结构)的链表,每一个vm_area_structs都描述了当前虚拟地址空间的一个区域。具体结构如下:
注意到每个区域都有一些相同的条目:
分页机制:通过将虚拟和物理内存分页,并且通过MMU建立起相应的映射关系,可以充分利用内存资源,便于管理。我们将物理内存分成4KB大小的页帧。之前所说线性地址和物理地址都是32位的(不涉及多级页表和TLB),n位的虚拟地址分成VPN+VPO,VPN是虚拟页号,VPO表示页内偏移。一个页面大小是4KB,所以VPO需要20位,VPN需要32-20=12位。CPU中的一个控制寄存器PTBR(页表基址寄存器)指向当前页表,MMU通过VPN来选择PTE(Page Table Entry),PTE(假设有效)中存放的即物理页号(PPN)。VPO和PPO是相同的。所以通过MMU,我们得到了线性地址相应的物理地址。注意页表一定是连续的,如果我们找到的PTE中有效位为0,MMU触发一次异常,调用缺页异常处理程序,程序更新PTE,控制交回原进程,再次执行触发缺页的指令。
假设32位地址空间,页面大小为4KB,每个页表项4字节,那么我们存放在内存中的页表有多大呢?答案是4MB。可见会造成很大的内存浪费。同时对于每一次地址请求,我们都要从逻辑地址—>线性地址—>物理地址。每一次都需要访问内存中的页表,但是有局部性原理 我们可以知道每次程序访问的物理地址是存放在同一个页帧中的,即每次我们访问的页面大概率只有几个,有没有什么办法可以加速这个过程呢?
答案是肯定的。首先我们可以通过多级分页机制减少因为存放页表造成的不必要的内存浪费。还可以仿照cache原理,建立一个页表缓冲器,将常访问的页表缓存在其中。
以二级页表为例,将VPN分成两部分VPN1,VPN2,分别作为一二级页表的索引。首先根据VPN1在一级页表中找到对应二级页表的基址,然后通过VPN2找到物流页的地址。这里可以看到二级页表是动态存在的,如果一级页表项无效,那么后面二级页表也不会存在,节省了大量内存空间。
TLB是一个小的,虚拟地址缓存,其中每一行都存放着一个由单个PTE组成的块。TLB通常具有高度的相联度。仍然是利用虚拟地址的VPN项,将VPN分成TLBI和TLBT两部分。如果给定一个有2^t个组的TLB,那么VPN的最低t位即作为TLB的index组索引。剩余为作为tag位,找到组中对应行。
给出完整的Corei7下的地址翻译:对于实际的intel Corei7内存系统,虚拟地址48位,物理地址52位(包括兼容模式:一个32位的虚拟\物理地址空间)结构如下:
它采用四级页表层次结构,总的翻译及取数过程如图:
一些说明:
下面我们将给出从VA到取回数据的整个过程。
首先VA经过MMU地址翻译:
内存映射:Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射。虚拟内存可以映射到两种类型的对象。
共享区域和私有区域的内存映射:
Fork函数内存映射:
Fork函数被当前进程调用时,内核为新进程创建各种数据结构,并且分配唯一PID。为了给新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表原样副本,将两个进程中的每个页面到标记为只读,同时设置每个区域结构为私有对象。
利用execve函数加载hello程序:
缺页故障处理:当CPU发送一个VA,发现对应的物理地址不在内存中,必须从磁盘中取出,就会发生故障,缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。
缺页中断处理:确定缺页是由于对合法虚拟地址进行合法的操作造成之后,系统选择一个牺牲页面,如果这个牺牲页面被修改过,将其交换出去,换入新页面并更新页表,当缺页处理程序返回后,CPU重启引起缺页指令。
动态内存分配是重要的,因为经常知道程序运行才知道某些数据结构的大小。
可以用低级的mmap和munmap函数来创建和删除虚拟内存区域。但动态内存分配器可以使用更方便,可移植性也更好。他维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,有已分配和空闲两种状态。
分配器有两种风格:
造成堆利用效率低的原因是碎片现象,碎片分为两种:
显然动态内存分配器需要解决分配块,合并块的要求,这就需要我们设计合适的数据结构来减少碎片,下面介绍两种:
注意,malloc返回的指针指向有效载荷的开始处。
如何填写块头部信息:(有效载荷+头部)按照对齐原则取整(8,16,24…)
因为头部的存在,所以向后合并是简单的,但是向前合并是不方便的。
knuth提出了边界标签方法,即在块中再添加一个脚步,作为头部的副本。根据内存的实际情况,设计合并的时候,被合并块不是目标块的前驱就是后继。这样我们就可以通过边界标签方法方便优雅的在常数时间完成对块的合并。
缺点:显然的我们对于每一个块都保持了一个头部一个脚部,增加了额外的内存开销。
优化:我们注意到只有当前面一个快是未分配的时候我们才会去引用脚部的信息,所以已分配快就将其作为有效载荷的一部分,只需要保有空闲块的脚部即可。
2) 显示空闲链表
注:
1、 对于隐式而言,每次需要利用宏计算前驱后继的地址,但是显示就很方便,直接将前驱后继的指针存放在块中,但是显然的占据了一部分有效载荷。
2、 后进先出:保障了释放块是常数时间,但是显然的对于最近常利用的块利用率较高,
按照地址顺序:释放块需要线性时间,但是首次适配比LIFO有更高的内存利用率。
7.10本章小结
我们常说程序就是数据+指令,但是当程序以进程的形式运行在CPU中,我们都需要从磁盘中把程序加载的内存中。这一章我们介绍了虚拟内存这一伟大的想法是如何简化加载和共享的过程。X86-64通过段页式的内存管理机制实现了从逻辑地址—>线性地址—>物理地址的转化。
设备的模型化:所有的 IO 设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O。
Unix I/O 接口统一操作:
1) 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
2) Shell 创建的每个进程都有三个打开的文件:标准输入,标准输出,标 准错误。
3) 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位 置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用 程序能够通过执行 seek,显式地将改变当前文件位置 k。
4) 读写文件:一个读操作就是从文件复制 n>0个字节到内存,从当前文件位置 k 开始,然后将 k增加到k+n,给定一个大小为 m 字节的而文件,当 k>=m 时,触发 EOF。类似一个写操作就是从内存中复制 n>0个字节到一个文件,从当前文件位置 k开始,然后更新 k。
5) 关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O 函数:
1)进程通过调用 open 函 数来打开一个存在的文件或是创建一个新文件的。 open函数将filename 转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在 进程中当前没有打开的最小描述符,flags 参数指明了进程打算如何访 问这个文件,mode 参数指定了新文件的访问权限位。
2)fd 是需要关闭的文件的描述符,close 返回操作结果。
3)read 函数从描述符为 fd 的当前文件位置赋值最多 n 个字节到内存位置 buf。返回值-1 表示一个错误,0 表示 EOF,否则返回值表示的是实际传送的字节数量。
write 函数从内存位置 buf 复制至多 n 个字节到描述符为 fd 的当前文件位置。
背景:windows下的printf和vsprintf
先看一看printf的源码:
在形参列表里有这么一个token:… ,这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。
先看va_list arg = (va_list)((char*)(&fmt) + 4);
va_list的定义:typedef char va_list
这说明它是一个字符指针。 其中的: (char)(&fmt) + 4) 表示的是…中的第一个参数。
下一句printf调用了一个函数vsprintf,vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
再下一句write(buf , i),还是先看一下write函数的内容:
其中int表示要调用中断门了。通过中断门,来实现特定的系统服务。 再跟踪syscall的实现
Syscall将字符串中的字节从寄存器复制到显卡的显存中,以ASCII字符形式。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
异步异常-键盘中断的处理::当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数。
了解系统级IO是重要的,有时你除了使用Unix I/O 以外别无选择。在某些重要的情况中,使用高级I/O 函数不太可能,或者不太合适。例如,标准I/O 库没有提供读取文件元数据的方式,例如文件大小或文件创建时间。同时这对于我们理解网络编程和并行流是很有帮助的。
Hello程序走到这里终于迎来的生命的终点,我们再来一起回顾一下他灿烂的一生:
一些想法:这次大作业做的很辛苦,但是很有收获。我们学习编程第一个接触的程序helloworld,看上去很简单,但是如果要深究,会发现计算机的水是相当深的。但是也很有趣,你会发现有许许多多解决问题的奇思妙想,你能通过这些更好的理解计算机的特点和魅力。
但是我还发现了令人失望的一点。我在搜索链接命令的时候遇到极大的困难,网上相关的资料太少了。不仅如此,我发现中文搜索系统相关知识的时候,获得的资源很有限,大部分网络上的资料都是网友自学CSAPP的读书笔记。像ELF格式文件,动态链接,信号,系统级编程这些内容很匮乏,远不如算法,代码这些东西多。反倒是英文搜索的内容更加全面,也更加的紧贴潮流。不得不让人感叹在计算机底层到硬件方面,中国的技术落后。