本文主要介绍hello程序在linux下是如何从一个.c文件一步步变成可执行文件的。对于在运行的过程中可能会出现的一些比较重要的问题,例如虚拟内存,IO等操作进行探究。
关键词:程序执行 CSAPP
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 5 -
5.3 可执行目标文件hello的格式........................................................................ - 8 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 10 -
6.3 Hello的fork进程创建过程......................................................................... - 10 -
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 -
8.2 简述Unix IO接口及其函数.......................................................................... - 13 -
第1章 概述
1.1 Hello简介
P2P过程:首先先有个hello.c的c程序文本,经过预处理->编译->汇编->链接四个步骤生成一个hello的二进制可执行文件,然后由shell新建一个进程给他执行。
020过程:shell执行他,为其映射出虚拟内存,然后在开始运行进程的时候分配并载入物理内存,开始执行hello的程序,将其output的东西显示到屏幕,然后hello进程结束,shell回收内存空间。
1.2 环境与工具
硬件环境:X64 CPU Intel Core i7 6700HQ; 3.2GHz; 16G RAM; 1TB HD Disk
软件环境:Microsoft Windows10 Home 64位; VMware Workstation 14 Pro; Ubuntu 18.04
开发工具:gcc,readelf,edb
1.3 中间结果
文件名称 | 作用 |
hello.c | 源代码 |
hello.i | 预处理之后的文本文件 |
hello.s | 编译之后的汇编文件 |
hello.o | 汇编之后的可重定位目标执行 |
hello | 连接之后的可执行目标文件 |
1.4 本章小结
本章主要简单介绍了hello的P2P,020过程,列出了本次实验信息:环境、中 间结果。
第2章 预处理
2.1 预处理的概念与作用
预处理是计算机在处理一个程序时所进行的第一步,他直接对.c文件进行初步处理将处理后的结果保存在.i文件中,随后计算机再利用其它部分接着对.i文件进行处理。
2.1.1预处理的概念
计算机用预处理器(cpp)来执行预处理操作,操作的对象就是原始代码中以字符#开头的命令,hello.c中就包含了三条这样会被预处理的语句,如下图2-1中所示的代码;除了调用库这样的操作之外,程序中的宏定义也会在预处理的时候处理,如图2-2所示;最后预处理阶段会将程序中的注释删除掉,因为这对程序接下来的操作是没有用的。
图2-1 hello.c中的头文件 图2-2 宏定义
2.1.2预处理的作用
预处理的过程中,对于引用一些封装的库或者代码的这些命令来说,他会告诉预处理器读取头文件中用到的库的代码,将这段代码直接插入到程序文件中;对于宏定义来说,会完成对宏定义的替换;注释会直接删除掉。最后将处理过后的新的文本保存在hello.i中,后面计算机将直接对hello.i进行操作。
预处理阶段的作用是让编译器在随后对文本进行编译的过程中,更加方便,因为访问库函数这类操作在预处理阶段已经完成,减少了编译器的工作。
2.2在Ubuntu下预处理的命令
首先先来介绍一下如何在shell中执行对.c文件的预处理操作:
linux> gcc –E –o hello.i hello.c
在这里运用-o操作,将结果输出到hello.i文件中,方便我们对预处理过后的文件进行查看。我们可以看一下预处理前后两个文件大小的差距,如图2-3所示,预处理前的hello.c文件只有534字节,而预处理后的hello.i文件有66102字节。可见预处理工作中对文本做了很大的改动和补充。
图2-3 预处理前后文件大小
2.3 Hello的预处理结果解析
我们在2.1节中说到过,预处理只对开头是#的命令进行操作。也就是说,对于我们程序中定义的变量、写的函数等这些操作,预处理阶段是不会管的,我们首先就来对比一下这一部分。如图2-4所示,在预处理之前,程序中包含开始的注释内容、头文件、全局变量和主函数。而右侧是预处理过后的文件,这里展示了文件的最后几行,可以看到,从全局变量的定义开始,与预处理之前的文件完全相同,这与2.1中的概念相符。
图2-4 预处理前后的程序文本
接下来我们回到hello.i文件的开头,从图2-5中可以看出,hello.i程序中并没有了注释部分。最后我们再来看hello.i文本的中间部分,首先我们看到左侧的图中从第13行开始有很多的地址,还有如右侧图中的一些代码部分,右侧图中就是一个结构体变量。这说明,预处理阶段,预处理器将需要用到的库的地址和库中的函数加入到了文本中,与我们原来不需要预处理的代码一同构成了hello.i文件,用来被编译器继续进行编译。
图2-5 hello.i文件内容
2.4 本章小结
预处理过程是计算机对程序进行操作的起始过程,在这个过程中预处理器会对hello.c文件进行初步的解释,对头文件、宏定义和注释进行操作,将程序中涉及到的库中的代码补充到程序中,将注释这个对于执行没有用的部分删除,最后将初步处理完成的文本保存在hello.i中,方便以后的内核器件直接使用。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译阶段是在预处理之后的下一个阶段,在预处理阶段过后,我们获得了一个hello.i文件,编译阶段就是编译器(ccl)对hello.i文件进行处理的过程。此阶段编译器会完成对代码的语法和语义的分析,生成汇编代码,并将这个代码保存在hello.s文件中。
3.1.2编译的作用
编译器会在编译阶段对代码的语法进行检查,如果出现了语法上的错误,会在这一阶段直接反馈回来,造成编译的失败。如果在语法语义等分析过后,不存在问题,编译器会生成一个过渡的代码,也就是汇编代码,在随后的步骤中,汇编器可以继续对生成的汇编代码进行操作。
这里有一个问题,就是为什么我们在预处理的过程中生成的比较大的hello.i文件,在进行完汇编过程后生成的hello.s文件又变小了。我们可以发现,hello.s文件中只存储了头文件之后的汇编代码,至于之前加入的头文件的代码具体去了哪里,会在第五章链接进行介绍。
3.2 在Ubuntu下编译的命令
我们可以利用如下指令来对hello.i文本继续进行编译:
linux> gcc –S hello.i –o hello.s
从图3-1中我们可以看到,利用上述命令编译过后,我们得到了一个hello.s
文件。
图3-1 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
我们将代码分成数据、赋值、类型转换、算数操作、控制转移数组/指针/结构操作和函数操作这么几个部分来具体分析一下。
3.3.1数据
关于数据的定义,我们可以看到hello.c中有一条如图3-2中的语句,这条语句定义了一个sleepsecs的全局变量。对应到hello.s文件中,就是图中右侧的部分。我们可以看到定义的过程中用.globl声明了这是一个全局变量;.type说明了类型是一个数据;.size说明了这个变量的大小,这里sleepsecs变量占了4个字节。关于.text和.type的具体含义及其作用会在第5章连接中进行讲解。
需要注意的一点是其实main函数中还定义了i变量,但是i由于是局部变量,所以汇编器并没有单独的对他进行处理,而是直接将在这个变量放到了寄存器中。具体是如何控制这个变量的,会在下一小节中介绍。
图3-2 数据的定义
3.3.2赋值
我们从上面的图中可以看到,在定义sleepsecs变量的过程中,同时对其进行了赋值在hello.s中。图3-3中的语句就是赋值操作转化成汇编之后的语句。可以看到一共有三行,第一行声明了变量名,第二行中保存的是变量的值,第三行中是存储的位置,也就是.rodata节中(第五章链接的内容)。有一个问题,那就是上图中的赋值语句中是将2.5赋值给了这个变量,为什么在汇编之后就变成了2。注意sleepsecs这个变量的类型是整型,但是2.5是一个浮点型,所以只保存了整数的部分,具体我们会在下一小节中介绍。
图3-3 sleepsecs的赋值
接下来我们看一下局部变量i是如何进行赋值的。可以注意到hello.s的36行中有一个对寄存器的寻址操作,这个操作将0放入到了这个地址中。可以确定这个就是对变量i的初始化,因为i是局部变量,所以直接可以在栈中用一个单元保存这个值,就不需要单独进行处理了。
图3-4 局部变量i的赋值
3.3.3类型转换
这里的类型转换采用的是隐式的转换。我们可以从上一节的sleepsecs的赋值中可以看出,汇编器并没有对类型转换做特别的代码上的处理,而是直接将2赋值给了sleepsecs。这说明汇编器在对hello.i文件进行汇编的过程中,直接在这个过程中进行了代码的优化,也就是说在编译的过程中就完成了浮点型的转换,将其赋值给了整型,而并没有在汇编代码中通过具体的代码实现,所以对于类型转换而言,是隐式的。
3.3.4算数操作
在hello.c的代码中,只涉及到了一处算数操作,就是在for循环中每次对i进行的加一操作。在3.3.2节中,我们已经看到了,i存储在寄存器中保存的一个地址中,所以如图3-5所示,对于i的运算,我们可以直接对寄存器保存的地址中的值进行操作,每次我们将这个值增加1。需要注意的一点是,每次我们进行的是寻址操作,是将地址中的值加1,而不是将地址加一。
图3-5 算数操作
3.3.5关系
在hello.c中有两个关系操作,分别如图3-6所示,一个是不等于操作,另一个是小于操作。具体到汇编代码中我们看到他们分别对应了两句操作。一个是cmpl另一个是一个j加上一些字母。
图3-6 逻辑操作
这里就需要用到汇编语言的相关知识了。首先cmpl是一个比较函数,这个函数中将比较的结果保存在条件码中。条件码中一共有四位,每一位都有不同的含义。如图3-7中所示。对于不同的比较结果,操作码中就保存了不同的值。关于下一行中保存的信息的具体作用,将在下一小节中进行介绍。
图3-7 操作码
3.3.6控制转移
我们看到,上一节中在每一个cmpl的操作之后,都紧跟着一个j的操作,这个j的含义就是jmp,起到控制函数跳转的作用,j后面跟的参数,就对应了在满足什么条件的时候进行跳转。图3-8中列出了不同跳转指令的含义。我们可以看到,对于每一种跳转指令都对应了跳转码的一种形式。所以我们就可以知道,为什么在上一节中,cmpl和j这两条语句总是同时出现。是因为在执行条件跳转的时候,我们必须利用到操作码中的值。所以在每个条件跳转之前,都肯定有一个比较指令对操作码进行设置。
图3-8 条件跳转指令
了解了条件跳转指令是如何执行的之后,我们可以看一下hello.s中具体的编译结果了。我们看图3-9中,右侧的汇编代码的29和30行在执行左侧代码中的if判断操作,通过3-8中的表格我们可以知道je是相等时跳转,也就是说,当argc等于3的时候,那么就跳转到.L2处执行,否则就继续向下执行。通过对L2的阅读以及L3中的操作我们可以知道,这是一个循环操作,也就正好对应了左侧代码中的情况。
通过上面对汇编代码的分析,我们可以了解汇编是如何控制程序在不同地方进行跳转的了。
图3-9 汇编中的条件跳转
3.3.7数组/指针/结构操作
hello.c中在输出的时候调用了argv数组中的元素。如图3-10所示,我们可以看到,在汇编中,我们已经没有了数组、结构等概念,我们有的只是地址和地址中存储的值。所以对于一个数组的保存,在汇编中我们只保存了他的起始地址,对应的也就是argv[0]的地址,对于数组的中其他元素,我们利用了数组在申请的过程中肯定是一段连续的地址这样的性质,直接用起始地址加上偏移量就得到了我们想要的元素的值。
图3-10 数组操作
3.3.8函数操作
图3-11中展示了如何对函数进行调用。首先我们应该先了解一下调用函数的过程中我们会用到哪些东西。
%eax寄存器中保存了函数的返回值。作为一个函数,我们肯定需要向函数内进行传参操作,对于参数比较少的情况来说,就直接存储在特定的寄存器中,如%rdi,%rsi,%rdx,%rcx就分别用来存储第一至四个参数。X86的及其一共为我们提供了6个寄存器来保存参数。如果参数多于6个,那么就只能放在栈中保存了。
如图中56行所示,我们直接利用call指令,后面加上调用函数的名称,就直接可以去到被调用的函数的位置。在被调用的函数执行完毕之后,程序会将函数的返回值存在%eax中,然后执行ret语句,将函数程序返回到调用的地方。这样就完成了整个的函数调用。
图3-11 函数操作
3.4 本章小结
本章我们主要介绍了编译器是如何将文本编译成汇编代码的。可以发现,编译器并不是死板的按照我们原来文本的顺序,逐条语句进行翻译下来的。编译器在编译的过程中,不近会对我们的代码做一些隐式的优化,而且会将原来代码中用到的跳转,循环等操作操作用控制转移等方法进行解析。最后生成我们需要的hello.s文件。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
汇编器(as)将hello.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在hello.o中。这里的hello.o是一个二进制文件。
4.1.2汇编的作用
我们知道,汇编代码也只是我们人可以看懂的代码,而机器并不能读懂,真正机器可以读懂并执行的是机器代码,也就是二进制代码。汇编的作用就是将我们之前再hello.s中保存的汇编代码翻译成可以攻机器执行的二进制代码,这样机器就可以根据这些01代码,真正的开始执行我们写的程序了。
4.2 在Ubuntu下汇编的命令
我们可以利用如下指令来对hello.s进行汇编:
linux> gcc –c hello.s –o hello.o
从图4-1中我们可以看到,利用上述命令编译过后,我们得到了一个hello.o
文件。
图4-1 Ubuntu下汇编的命令
4.3 可重定位目标elf格式
首先先来了解一下ELF格式中都存储了哪些文件,如图4-2所示,ELF中存储了很多不同的节的信息,每一个节中保存了程序中对应的一些变量或者重定位等这些信息,至于为什么要保存这些信息,是因为程序在链接的时候会用到这些信息。这些信息的含义以及链接的作用我们会在下一章中进行介绍。
图4-2 典型的ELF可重定位目标文件
图4-3 各个节节头的信息
根据readelf命令的结果,可以获得ELF文件的一些信息。图4-3中展示了ELF可重定位文件中各个节节头的信息。偏移量这一栏中保存了在hello.o这个二进制文件中,对应的节保存在相对于起始地址偏移了这么多的地方,也就是每一节存在了hello.o中得到哪一个位置上。
图4-4中保存了hello.o中的两个可重定位节中保存的具体信息,分别是.rela.text和.tela.eh.frame。
.rela.text中保存了代码的重定位信息,也就是.text节中的信息的重定位信息。可以看到这里面有.rodata,puts等很多代码的重定位信息。我们就拿第一条的信息来做分析。首先偏移量中保存了这个重定位信息在当前重定位节中的偏移量,也就是这个重定位信息的存储位置。第二个是信息这个里面保存了两个信息,前面的2个字节的信息保存了这个代码在汇编代码中被引用的时候的地址相对于所有汇编代码的偏移量,也就是这个代码具体在那个位置被调用了。后面4个字节保存了重定位类型,一个是绝对引用,另一个是相对引用。这也与后面一栏的类型相对应。后面的符号值和符号名称就比较好理解了,保存了代码段中具体符号的信息。
图4-4 可重定位节
最后还有一个符号表.symtab的信息。这个节中存放了在程序中定义和引用的函数和全局变量的信息。我们在图4-5中可以看到,有两个比较明显的变量,一个就是sleepsecs,另一个是main。这两个分别是全局变量和定义的函数。size这一栏中保存了他们的大小,可以看到因为main是一个函数,所以内容相对较多,占得空间比较大。后面的type保存了变量类型,可以看到main中对应的类型就是FUNC,也就是函数。由于后面的一些符号还没有进行链接这一步所以暂时没有信息。
图4-5 .symtab节
4.4 Hello.o的结果解析
用objdump命令进行反汇编过后,得到了如图4-6中所示的代码:
图4-6 hello.o的反汇编代码
对比第三章中的汇编代码可以发现,hello.o的反汇编程序多出来了上图中框出来的三个部分,我们依次分析一下多了的这些信息。
首先是红框中的信息。红框中保存了每一条指令的运行时地址,可以看到main函数的初始地址是0,然后依次向下增加。其实在后面的章节中会讲到,这个地址只是一个虚拟地址,而不是程序真正执行的地址。
蓝色的框中保存了一些16进制的代码。可以发现每一个红框中的指令地址的变化量都是蓝色框中一行的字节数。这就可以确定,蓝色框中保存的是16进制下的机器指令。我们可以看到地址为1和8的这两行,都对应了mov操作,所以对应的16进制的机器码都是48,这也就说明mov的机器码是48。
黄色框中的这段代码,我们看到底下多了一行信息。这一行代码声明了这个变量的具体类型。同时注意到偏移地址为26的这一指令,这个call操作也不是想第三章一样直接call对应的函数名字了,而是一个具体的相对地址,能够让程序在跑的过程中,直接跳转到的地方。
总体来说,hello.o文件的反汇编代码中最主要的就是增加了地址这个概念,将代码中的一切信息与地址联系起来。这样做的目的是因为在程序运行的过程中,都是在进行地址操作,所以hello.o文件可以说更接近了计算机可以执行的文件。
4.5 本章小结
汇编器将汇编代码处理成机器可以看懂的机器码,也就是二进制代码。二进制代码较汇编代码来说,虽然可读性变得比较差,但是在执行效率方面有了非常大的提升,汇编代码虽然已经在原来的文本的基础上进行了优化,但是还是存在着一些字符等不能够直接处理的数据。但是二进制代码中,已经将所有的指令、函数名字等量变成了相应的存储地址,这样机器就可以直接读取这些代码并执行。所以总的来说hello.o已经非常接近一个机器可以执行的代码了。
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
链接是通过链接器(ld)将各种代码和数据片断收集并组合成一个单一文件的过程。这个文件可以被加载(复制)到内存并执行。
5.1.2链接的作用
因为有了链接这个概念的存在,所以我们的代码才回变得比较方便和简洁,同时可移植性强,模块化程度比较高。因为链接的过程可以使我们将程序封装成很多的模块,我们在变成的过程中只用写主程序的部分,对于其他的部分我们有些可以直接调用模块,就像C中的printf一样。
作为编译的多后一步链接,就是处理当前程序调用其他模块的操作,将该调用的模块中的代码组合到相应的可执行文件中去。
5.2 在Ubuntu下链接的命令
我们可以利用如下指令来对hello.o进行汇编:
linux> ld /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/5/crtbeginT.o -L/usr/lib/gcc/x86_64-linux-gnu/5 hello.o -lc -lgcc -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -o hello
从图5-1中我们可以看到,利用上述命令编译过后,我们得到了一个hello.o
文件。
图5-1 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
图5-2 ELF各节信息
图5-2中保存了可执行文件hello中的各个节的信息。可以看到hello文件中的节的数目比hello.o中多了很多,说明在链接过后有很多文件有添加了进来。下面列出每一节中各个信息条目的含义:
名称和大小这个条目中存储了每一个节的名称和这个节在重定位文件种所占的大小。
地址这个条目中,保存了各个节在重定位文件中的具体位置也就是地址。 偏移量这一栏中保存的是这个节在程序里面的地址的偏移量,也就是相对地址。
5.4 hello的虚拟地址空间
图5-3中分别是edb显示的hello文件的信息和5.3中输出的ELF文件的信息。我们可以着重看一下红框中的部分,这一部分中存储的ELF头信息,也就是ELF文件最开始存的数据。可以看到通过这两种方式得到的信息是完全相同的。再来说一下这个信息的含义,我们看到右侧的ELF头下面有很多文字注释,这其实就是ELF头中存储的信息,即ELF整个文件的基本信息。
图5-3 ELF文件信息对比
有一个比较奇怪的问题,就是在5.3中我们可以看到,在第17项开始的时候,地址就发生了一个比较大的变化。而edb中也没有显示出来这些节的信息。这是因为这些节中保存了共享库中的信息。在edb中如果想获得这些信息,需要单独进行查看,如图5-4所示,就是从.dynamic节开始的信息。
图5-4 .dynamic节及其后面节的信息
5.5 链接的重定位过程分析
首先先看一下main函数中有哪些不一样的地方,如图5-5所示,图中上面的程序是hello.o文件中main函数中的的一部分代码,下面的代码是hello文件中对应部分的反汇编代码。可以发现,在链接之前,hello.o中的注释仅仅是对main函数的一个便宜量,并且相应的汇编代码中lea后对于%rip的偏移量也是0,也就是说对于hello.o来说,我们并不能准确的了解到这段代码的含义。
再看链接之后的反汇编,可以看到反汇编之后,代码注释中的内容直接变成了系统的IO库中的函数,lea后面跟的偏移量也是正确的偏移量了。接下来的call指令也是一样,在hello中准确的指明了具体调用的函数,而hello.o文件中也只有一个main函数的偏移量。
可以看到,在链接的过程中,链接器会将我们链接的库函数或者其他文件在可执行文件中准确的定位出来。
图5-5 hello.o与hello的反汇编
可以看到,在hello.o的反汇编代码中,只有一个main函数,但是对于hello的反汇编代码来说,可以看到很多如_init样子的函数。这些函数都是在链接的过程中,被加载到可执行文件中的。
通过上面的对比可以看到,在链接的过程中,链接器会进行如下几个过程:
将代码、符号、变量、函数等进行重定位,使这些元素在可执行文件中可以有明确的虚拟内存地址。具体的执行方式就是用.o文件中的重定位条目,这个条目告诉链接器应该如何修改这个引用的地址。
将调用的函数都加载到可执行文件中,使其变成一个完整的文件,在文件中涉及到的任何符号或者函数等信息在文件中都有定义。
接下来分析一下链接器是如何进行重定位的。我们就对图5-6中红框中的语句的重定位进行分析。首先左侧存储了hello.o中代码节的重定位条目。红框中的第一个信息偏移量存储的是这个符号的出现位置的偏移量,这里是0x1d,对应于右侧红框中的我们可以看到是这个call函数的位置,这段指令的起始地址是0x1c,由于call函数的机器码是e8占了一个字节,所以真正的符号出现的地址是0x1d,这两个地址相同,说明这个重定位条目对应的是这个符号。
图5-6 hello.o的重定位条目
第二个信息的前两个字节保存了这个符号在符号表中的偏移量,可以看到红框中的信息为0xc,对应在符号表中可以看到,puts在符号表中的偏移量是12,对应的十六进制的值就是0xc,说明了被重定位的符号应该是puts。后面的类型保存了是相对地址还是绝对地址。
图5-7 hello.o中的符号表
总体来说,重定位的过程就是应用重定位文件中存储的信息,在对应的符号表和汇编代码中将要重定位的符号或者函数的位置准确的放到可执行文件中。
5.6 hello的执行流程
hello在执行的过程中一共要执行三个大的过程,分别是载入、执行和退出。载入过程的作用是将程序初始化,等初始化完成后,程序才能够开始正常的执行。如图5-7所示,由于hello程序只有一个main函数,所以在程序执行的时候主要都是在main函数中。又因为main函数中调用了很多其它的库函数,所以可以看到,在main函数执行的过程中,会出现很多其他的函数。
图5-7 hello的执行流程
5.7 Hello的动态链接分析
如图5-8中所示,在执行函数dl_init的前后,地址0x600ff0中的值由0发生了变化。我们可以借助图5-2中的信息,得到这个地址是.got节的开始,而got中是一个全局函数表。这就说明,这个表中的信息是在程序执行的过程中动态的链接进来的。也就是说,我们在之前重定位等一系列工作中,用到的地址都是虚拟地址,而我们需要的真实的地址信息会在程序执行的过程中用动态链接的方式加入到程序中。当我们每次从PLT表中查看数据的时候,会首先根据PLT表访问GOT表,得到了真实地址之后再进行操作。
图5-8 dl_init前后文件变化
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
链接的过程,是将原来的只保存了你写的函数的代码与代码用所用的库函数合并的一个过程。在这个过程中链接器会为每个符号、函数等信息重新分配虚拟内存地址,方法就是用每个.o文件中的重定位节与其它的节想配合,算出正确的地址。同时,将你会用到的库函数加载(复制)到可执行文件中。这些信息一同构成了一个完整的计算机可以运行的文件。链接让我们的程序做到了很好的模块化,我们只需要写我们的主要代码,对于读入、IO等操作,可以直接与封装的模块相链接,这样大大的简化了代码的书写难度。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程的经典定义就是一个执行中的程序的实例。
6.1.2进程的作用
通过进程这个概念,我们在运行一个程序的过程中会得到一个假象,就好像我们的程序时系统中当前运行的唯一的程序一样。我们的程序好像是独占的使用处理器和内存。处理器好像就是无间断的一条接一条的执行我们程序中的指令。最后我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个linux中提供的应用程序,他在操作系统中为用户与内核之间提供了一个交互的界面,用户可以通过这个界面访问操作系统的内核服务。他的处理流程如下:
- 从界面中读取用户的输入。
- 将输入的内容转化成对应的参数。
- 如果是内核命令就直接执行,否则就为其分配新的子进程继续运行。
- 在运行期间,监控shell界面内是否有键盘输入的命令,如果有需要作出相应的反应
6.3 Hello的fork进程创建过程
首先先来了解一下fork函数的机制。父进程通过调用fork函数创建一个新的子进程。新创建的子进程几乎但不完全与子进程相同。在创建子进程的过程中,内核会将父进程的代码、数据段、堆、共享库以及用户栈这些信息全部复制给子进程,同时子进程还可以读父进程打开的副本。唯一的不同就是他们的PID,这说明,虽然父进程与子进程所用到的信息几乎是完全相同的,但是这两个程序却是相互独立的,各自有自己独有的用户栈等信息。
fork函数虽然只会被调用一次,但是在返回的时候却有两次。在父进程中,fork函数返回子进程的PID;在子进程中,fork函数返回0。这就提供了一种用fork函数的返回值来区分父进程和子进程的方法。
同时fork在使用的过程中,有一个令人比较头疼的问题,就是父进程和子进程是并发执行的所以我们不能够准确的知道那个进程先执行或者先结束。这也就造成了每次执行的输出结果可能是不同的,也是不可预测的。
图6-1中展示了一个程序在调用了fork函数之后的行为。原程序中,在子进程中新型了++x操作,在父进程中进行了—x操作。通过进程图我们可以看到,两个进程的输出分别为2和0,证实了我们上面说过的两个进程是独立的。
图6-1 一个调用fork函数代码的进程图
6.4 Hello的execve过程
execve函数的作用是在当前进程的上下文中加载并运行一个新的程序。与fork函数不同的是,fork函数创建了一个新的进程来运行另一个程序,而execve直接在当前的进程中删除当前进程中现有的虚拟内存段,并穿件一组新的代码、数据、堆和用户栈的段。将栈和堆初始化为0,代码段与数据段初始化为可执行文件中的内容,最后将PC指向_start的地址。在CPU开始引用被映射的虚拟页的时候,内核才会将需要用到的数据从磁盘中放入内存中。
图6-2中展示了相应的系统映像。
图6-2 系统映像
6.5 Hello的进程执行
我们在之前的小节中已经提到过了,当前的CPU中并不是只有我们一个程序在运行,这只是一个假象,实际上有很多进程需要执行。要了解具体是怎样进行的,首先先了解几个概念。
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由 通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内 核数据结构等对象的值构成。
进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式与内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄 存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的 代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任 何命令,并且可以访问系统中的任何内存位置。
了解了一些基本概念之后,我们来分析一下hello程序中的具体执行情况。图6-3中展示了hello的代码中会主动引起中断的一个代码。
图6-3 hello文件的部分代码
这段代码中调用了sleep函数, 我们知道这个函数中用到的参数的值为2,所以这个sleep函数的作用就是当运行到这一句的时候,程序会产生一个中断,内核会将这个进程挂起,然后运行其它程序,当内核中的计时器到了2秒钟的时候,会传一个时间中断给CPU,这时候CPU会将之前挂起的进程放到运行队列中继续执行。
从图6-4中我们可以比较清晰的看出CPU是如何在程序建进行切换的。假设hello进程在sleep之前一直在顺序执行。在执行到sleep函数的时候,切换到内核模式,将hello进程挂起,然后切换到用户模式执行其它进程。当到了2秒之后,发生一个中断,切换到内核模式,继续运行之前被挂起的进程。最后切换回用户模式,继续运行hello进程。
图6-4 hello进程的上下文切换
6.6 hello的异常与信号处理
图6-5中显示了hello程序正常运行的结果。可以看到在执行ps命令之后,程序后台并没有hello进程正在执行了,说明进程正常结束,已经被回收了。
图6-5 正常运行
图6-6展示了在进程运行的过程中从键盘输入Ctrl+Z命令后的结果。可以看到在执行完三次循环之后,按下键盘,shell父进程会收到一个SIGSTP信号,这个信号的功能是将程序挂起并且放到后台。通过ps命令我们可以看到,hello命令并没有结束。接下来我们用fg命令将JID最大的放到前台,也就是刚刚挂起的hello进程,可以看到进程又继续执行完了剩下的7次循环。
图6-6 执行Ctrl+Z后
图6-7中是输入Ctrl+C操作后的结果。从键盘中输入Ctrl+C后,shell父进程会收到一个SIGINT信号,这个信号的功能是直接将子进程结束。可以在ps中看到,已经没有了hello进程。说明Ctrl+C会直接将进程结束并回收掉。
图6-7 执行Ctrl+C操作
jobs命令可以查看当前执行的关键操作是什么,比如我们执行了一个Ctrl+Z命令将进程挂起后,用jobs命令就能看到Ctrl+Z这条命令。
图6-8 jobs命令
如图6-9所示,pstree命令将所有的进程按照树状结构打印出来。这样我们就可以知道不同进程之间的关系。
图6-9 pstree命令
6.7本章小结
本章中阐述了进程的概念以及他在计算机中具体是如何在使用的。其次,还介绍了如何利用shell这个平台来对进程进行监理调用或发送信号等一系列操作。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:又称相对地址,是程序运行由CPU产生的与段相关的偏移地址部分。他是描述一个程序运行段的地址。
物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。他是在前端总线上传输的而且是唯一的。在hello程序中,他就表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。
线性地址:是经过段机制转化之后用于描述程序分页信息的地址。他是对程序运行区块的一个抽象映射。
虚拟地址:其实虚拟地址跟线性地址是一个东西,都是对程序运行区块的相对映射。
就hello而言,他是在物理地址上运行的,但是对于CPU而言,CPU看到的hello运行的地址是逻辑地址,在具体操作的过程中,CPU会将逻辑地址转换成线性地址再变成物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图7-1所示:
图7-1 段选择符
索引号是“段描述符(segment descriptor)”的索引,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,每一个段描述符由8个字节组成,如图7-2:
图7-2 段选择符
其中Base字段,它描述了一个段的开始位置的线性地址,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中,由段选择符中的T1字段表示选择使用哪个,=0,表示用GDT=1表示用LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。如下图:
图7-3 概念关系说明
具体的转换步骤如下:
- 给定一个完整的逻辑地址[段选择符:段内偏移地址。
- 看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。可以得到一个数组。
- 取出段选择符中前13位,在数组中查找到对应的段描述符,得到Base,也就是基地址。
- 线性地址 = Base + offset。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址到物理地址的转换是通过页的这个概念完成的。线性地址被分为以固定长度为单位的组,称为页。
首先 Linux 系统有自己的虚拟内存系统,其虚拟内存组织形式如图 7-4所示,Linux 将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd 指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct 的链表,一个链表条目对应一个段,所以链表相连指出了 hello 进程虚拟内存中的所有段。
图7-4 Linux是如何组织虚拟内存的
CPU芯片上有一个专门的硬件叫做内存管理单元(MMU),这个硬件的功能就是动态的将虚拟地址翻译成物理地址的。这个表示如何工作的呢,如图7-5所示。N为的虚拟地址包含两个部分,一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE(页表条目)。接下来在对应的PTE中获得PPN(物理页号),将PPN与VPO串联起来,就得到了相应的物理地址。
图7-5 使用页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
首先来介绍一下TLB具体是什么东西。我们注意到,每次在进行虚拟地址翻译的过程中都会有访问PTE的操作,如果在比较极端的情况下,就会存在访存的操作,这样的效率是很低的。TLB的运用,就可以将PTE上的数据缓存在L1中,也就是TLB这样一个专用的部件,他会将不同组中的PTE缓存在不同的位置,提高地址翻译的效率。
其次我们来介绍一下多级页表的概念。在前面我们了解了一级页表是如何进行工作的。可以发现一级页表有一个弊端,就是对于每一个程序,内核都会给他分配一个固定大小的页表,这样有一些比较小的程序会用不到开出的页表的一些部分,就造成了空间的浪费,多级页表就很好的解决了这个问题。以二级页表为例,首先我们先开一个比较小的一级页表,我们将完整的页表分组,分别对应到开出来的一节页表的一个PTE中,在执行程序的过程中,如果我们用到了一个特定的页表,那么我们就在一级页表后面动态的开出来,如果没用到就不开,这样就大大的节省了空间。
知道了上述概念之后,我们就来看一下虚拟地址是如何在四级页表中转换的。如图 7-6,CPU产生虚拟地址VA,VA传送给MMU,MMU使用前36位VPN作为TLBT(前32位+TLBI(后4位)向TLB中匹配,如果命中,则得到 PPN (40bit)与VPO(12bit)组合成 PA(52bit)。如果TLB中没有命中,MMU 向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出 PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到 PPN,与VPO组合成PA,并且向TLB 中添加条目。
图7-6 四级页表下的地址翻译情况
7.5 三级Cache支持下的物理内存访问
在上一节中我们已经获得了物理地址VA,我们接着图7-6的右侧部分进行说明。使用CI(后六位再后六位)进行组索引,每组8路,对8路的块分别匹配 CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO(后六位)取出数据返回。 如果没有匹配成功或者匹配成功但是标志位是 1,则不命中(miss),向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略 LFU 进行替换。
7.6 hello进程fork时的内存映射
在7.3节中我们已经提到过了mm_struct和vm_area_struct这两个标记符,这里我们就需要用到他们。先来介绍一下:
mm_struct(内存描述符):描述了一个进程的整个虚拟内存空间。
vm_area_struct(区域结构描述符):描述了进程的虚拟内存空间的一个区间。
在用fork创建内存的时候,我们需要以下三个步骤:
- 创建当前进程的mm_struct,vm_area_struct和页表的原样副本。
- 两个进程的每个页面都标记为只读页面。
- 两个进程的每个vm_area_struct都标记为私有,这样就只能在写入时复制。
7.7 hello进程execve时的内存映射
execve函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效的替代了当前程序。加载并运行hello需要以下几个步骤:
- 删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。图7.7 概括了私有区域的不同映射。
- 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页现象的发生是由于页表只相当于磁盘的一个缓存,所以不可能保存磁盘中全部的信息,对于有些信息的查询就会出现查询失败的情况,也就是缺页。
对于一个访问虚拟内存的指令来说,如果发生了缺页现象,CPU就会触发一个缺页异常。缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,例如图7-7中存放在PP3中的VP4,如果VP4已经被更改,那就先将他存回到磁盘中。
找到了要存储的页后,内核会从磁盘中将需要访问的内存,例如图7-7所示的VP3放入到之前已经操作过的PP3中,并且将PTE中的信息更新,这样就成功的将一个物理地址缓存在了页表中。当异常处理返回的时候,CPU会重新执行访问虚拟内存的操作,这个时候就可以正常的访问,不会发生缺页现象了。
图7-7 缺页现象
7.9动态存储分配管理
7.9.1动态内存分配器的基本原理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为 一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已 分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用 来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已 分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分 配器自身隐式执行的。 分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。例如C程序中的malloc和free。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么 就释放这个块, 自动释放未使用的已经分配的块的过程叫做垃圾收集。 例如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的内存。
7.9.2隐式空闲链表分配器原理
隐式空闲链表有两种形式,我们分别来介绍一下。
图8-8展示的是第一种形式。首先说明一下每个部分的意义。头部一共四个字节,前三个字节存储的是块的大小,最后一个字节存储的是当前这个块是空闲块还是已分配的块,0代表空闲块,1代表已分配的块。中间的有效载荷就是用于存放已分配的块中的信息用的。最后的填充部分是为了地址对齐等一些要求用的。
图7-8 一个简单的堆块格式
既然是链表,隐式链表的结构就是根据地址从小到大进行连接的,如图7-9。其中的每一个元素表示的是一个空闲块或者一个分配块,由于空闲块会合并我的特性,链表中的元素的连接一定是空闲块的分配块交替连接的。
至于空闲块是如何进行合并的,因为有了 Footer,所以我们可以方便的对前面的空闲块进行合并。合并的 情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四 种情况分别进行空闲块合并,我们只需要通过改变 Header 和 Footer 中的值就可以 完成这一操作。
图7-9 隐式空闲链表结构
图7-10是隐式的另一种结构,可以看到与上面的不同的是,只一种结构在最后多了一个与头部相同的结构,这个结构叫做脚部。这个新的结构的作用就是为了在空闲块合并的时候比较方便高效。因为如果利用之前的结构,在合并前面的空闲块的时候,由于我们不知道前面的块的大小,所以我们不能获得前面块的起始位置,这样就只能从链表的开始来找一遍。有了脚部,我们就可以利用脚部中存储的信息来获得前一个块中的地址。
图7-10 使用边界标记的堆块格式
7.9.3显式空闲链表基本原理
图7-11是显示空闲链表的格式,可以看到,与隐式结构不同的是,显示结构在空闲块中增加了8个字节,分别保存当前空闲块的前驱空闲块的地址和后继空闲块的地址。也就是说,显式的结构比隐式结构多维护了一个链表,就是空闲块的链表。这样做的好处就是在我们在malloc的时候,隐式的方法是要遍历所有的块,包括空闲块了分配块。但是显式的结构只需要在空闲块中维护的链表检索就可以了,这样降低了在malloc时候的复杂度。
关于空闲块的维护方式一共有两种,一种是后进先出的方式,另一种是按照地址的方式。按照地址维护很好理解,与隐式的结构大致相同。后进先出的方式的思想是,当一个分配块被free之后,将这个块放到链表的最开头,这样在malloc的时候会首先看一下最后被free的块是否符合要求。这样的好处是释放一个块的时候比较高效,直接放在头部就可以。
图7-11 使用双向空闲链表的堆块格式
7.10本章小结
本章介绍了储存器的地址空间,讲述了虚拟地址、物理地址、线性地址、逻辑地址的概念,还有进程fork和execve时的内存映射的内容。描述了系统如何应对那些缺页异常,最后描述了malloc的内存分配管理机制(C语言为例)。可以看到真正高效的运行起一个程序来是很复杂的。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。
8.2 简述Unix IO接口及其函数
Unix I/O 接口统一操作:
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
- Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
- 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
- 读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O 函数:
- int open(char* filename,int flags,mode_t mode)
这个函数会打开一个已经存在的文件或者创建一个新的文件。
- int close(fd)
这个函数会关闭一个打开的文件。
- ssize_t read(int fd,void *buf,size_t n)
这个函数会从当前文件位置复制字节到内存位置。
- ssize_t wirte(int fd,const void *buf,size_t n)
这个函数从内存复制字节到当前文件位置。
8.3 printf的实现分析
printf需要做的事情是:接受一fmt的格式,然后将匹配到的参数按照fmt格式输出。图8-1是printf的代码,我们可以发现,他调用了两个外部函数,一个是vsprintf,还有一个是write。
图8-1 printf函数的代码
从图8-2中的vsprintf函数可以看出,这个函数的作用是将所有的参数内容格式化后存入buf,然后返回格式化数组的长度。
write函数是将buf中的i个元素写到终端的函数。
Printf的运行过程:
从vsprintf生成显示信息,显示信息传送到write系统函数,write函数再陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序。从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
图8-2 vsprintf函数
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成ASCII码,保存到系统的键盘缓冲区之中。
图8-3中展示了getchar的代码,可以看出,这里面的getchar调用了一个read函数,这个read数是将整个缓冲区都读到了buf里面,然后将返回值是缓冲区的长度。我们可以发现,如果buf长度为0,getchar才会调用read函数,否则是直接将保存的buf中的最前面的元素返回。
图8-3 getchar函数代码
8.5本章小结
本章节讲述了一下linux的I/O设备管理机制,了解了开、关、读、写、转移文件的接口及相关函数,简单分析了printf和getchar函数的实现方法以及操作过程。
结论
hello程序终于走完了他一生的过程,然我们来回顾一下他从一个.c文件是怎样一步一步的变成可以输出我们想看到的结果的程序:
- 我们首先通过各种各样的文本编辑器,将我们用高级语言编写的程序存到了hello.c文件中。
- 预处理器将hello.c文件经过初步的修改变成了hello.i文件。
- 接着编译器将hello.i文件处理成为了汇编代码并保存在了hello.s文件中。
- 然后汇编器将hello.s文件处理成了可重定位的目标程序,也就是hello.o文件,这个时候,我们的程序离可以运行就只差一步了。
- 最后链接器将我们的hello.o与外部文件进行链接,终于我们得到了可以跑起来的hello文件了。
- 当我们在shell中输入运行hello文件的命令的时候,内核会为我们分配好运行程序所需要的堆、用户栈、虚拟内存等一系列信息。方便我们的hello程序能够正常的运行。
- 当我们需要从外部对hello程序进行操控的时候,我们只需要在键盘上给一个相应的信号,他就会按照我们的指令来执行。
- 当我们的hello需要访问磁盘中的信息的时候,这时候CPU看到了他找不到的地址VA,他利用自己的工具MMU将他翻译成了可以看懂的地址。
- 最后当我们的hello执行完所有工作之后,他也就结束了字节的一生,最后被shell回收掉了。
通过这次大作业,我更加全面系统的了解了这门课程,对书中的知识有了更加全面的认识。同时感受到了计算机系统的复杂性以及严密性。我们一个程序的成功运行需要多少计算机硬件和软件的共同配合。
附件
文件名称 | 作用 |
hello.c | 源代码 |
hello.i | 预处理之后的文本文件 |
hello.s | 编译之后的汇编文件 |
hello.o | 汇编之后的可重定位目标执行 |
hello | 连接之后的可执行目标文件 |
参考文献
[1] Bryant,R.E. 深入理解计算机系统
[2] LINUX 逻辑地址、线性地址、物理地址和虚拟地址
转:https://www.cnblogs.com/zengkefu/p/5452792.html
[3] Linux内核中的printf实现
https://blog.csdn.net/u012158332/article/details/78675427