摘 要
本文是关于hello.c这一C语言程序,从源代码到可执行文件,再加载到内存中执行的相关过程的介绍。内容大部分是基于计算机系统相关知识的分析。
关键词:计算机系统;Intel Core i7;程序;C标准库
目 录
第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本章小结
结论
附件
参考文献
第1章 概述
1.1 Hello简介
第一部分:P2P的过程
1.预处理阶段:
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。在hello.c文件中,前三行预处理命令#include <stdio.h> #include <unistd.h> #include <stdlib.h>告诉预处理器读取系统头文件stdio.h,unistd.h,stdlib.h的内容,并把它直接插入程序文本中,得到另一个C程序hello.i。我们可以看到,原始C程序只有29行内容,在经过预处理之后,变成了3118行。增加的部分,就是预处理命令中的三个头文件的内容。
2.编译阶段:
编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。该程序包含函数main的定义。
3.汇编阶段:
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序,并将结果保存在目标文件hello.o中。hello.o是一个二进制文件,它包含的是函数main的指令编码。
4.链接阶段
hello.c中包含对printf函数、exit函数和sleep函数的调用,它们 都是标准C库中的函数,均存在于一个以.o为后缀的单独的预编译好了的目标文件中,这个文件通过链接合并到hello.o程序中,链接是由链接器(ld)负责处理的,链接后得到可执行目标文件,这样,hello程序就可以被加载到内存中由系统执行了。
此后,shell为hello程序(Program)Fork,产生子进程(Process)。至此,第一部分P2P完成。
第二部分:020的过程
shell使用execve,映射虚拟内存,进入程序入口,从物理内存加载代码和数据,进入main函数执行目标代码。CPU为hello程序分配时间片执行逻辑控制流。运行结束后,hello进程被父进程shell回收,相关的数据结构都被删除。至此,第二部分020完成。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk
软件环境:Windows10 64位;Vmware 11以上;Ubuntu 16.04 LTS 64位
开发工具:Code::Blocks
调试工具:gcc gdb edb-debugger readelf
1.3 中间结果
hello.i:包含预处理命令经预处理器修改后的内容,即将stdio.h,unistd.h,stdlib.h三个头文件的内容插入到hello.c程序后的内容。
hello.s:hello.c编译后得到的汇编语言程序。汇编语言为不同高级语言的不同编译器提供了通用的输出语言,可以接着汇编成机器指令。
hello.o:可重定位目标程序,区别于hello.c,hello.i,hello.s这些文本文件,hello.o是一个二进制文件,内容是函数main的机器指令,它可以和C库函数的可重定位目标文件链接得到可执行文件。
helloo.txt:hello.o链接前的反汇编代码,用于与hello.s进行比较。
hello:链接后得到的可执行文件,可以加载到内存中由系统执行。
hello.elf:hello.o链接后的elf文件,帮助分析链接过程。
hello.txt:hello.o链接后的反汇编代码,用于与helloo.txt比较并分析链接过程。
1.4 本章小结
hello.c从原始C程序一步一步生成可执行文件hello的过程,在本章中进行了概括。然而,这只是一个非常简略的过程描述。其中的很多过程,比如预处理、编译、汇编和链接的细节都未得到体现。要深入了解这些过程,还需要借助更多工具进行更加深入的分析。
第2章 预处理
2.1 预处理的概念与作用
程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
2.2在Ubuntu下预处理的命令
图2.1 预处理
其中hello.i就是经预处理得到的文本文件。
2.3 Hello的预处理结果解析
预处理处理命令#include <stdio.h> #include <unistd.h> #include <stdlib.h>,即对头文件stdio.h,unistd.h和stdlib.h的引用(程序中包含对这三个头文件中的函数的调用,需要与函数对应的可重定位目标文件进行链接)
预处理后,文件增长至3118行,对头文件的引用#include被替换成头文件的相应内容,宏定义#define命令要求预处理器将相应常量名替换为常量的值,#ifdef这样的条件编译命令,预处理器确定条件是否成立后,决定是否需要进行编译,并将需要进行编译的部分也写入hello.i文件。
在hello.i文件中我们可以看到,程序中多了一些函数声明、类型定义和一些全局变量的定义,而不再含有以#开头的语句,这些就是三个头文件的内容(头文件中宏定义也由预处理器进行了处理)。有这些内容之后,我们就可以在程序中调用C标准库中的函数,使用同一类型但是名称不尽相同的变量,以及一些已经定义的全局变量、结构体等数据结构。
2.4 本章小结
预处理是对文件接下来的编译、汇编乃至于链接的准备,我们在hello.c中不必将可能需要的定义全部列出,只需在最前面声明包含哪些头文件就可以了,预处理器帮助我们解析这些预处理命令的内容。
第3章 编译
3.1 编译的概念与作用
编译是指编译器(ccl)将文本文件hello.i翻译成文本文件hello.s的过程。文本文件hello.s中包含一个汇编语言程序,该程序包含函数main的定义。
编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
3.2 在Ubuntu下编译的命令
图3.1 编译
其中hello.s就是经编译得到的文本文件。
3.3 Hello的编译结果解析
3.3.1 伪指令部分
图3.2 伪指令
这部分是指导汇编器和链接器工作的伪指令。
.file:源文件的名字。这里为"hello.c"。
.text:表示代码段。
.globl:表示(已赋初值的)全局变量。hello.c中有一个全局变量sleepsecs。
.data:表示数据段。
.align(第一个):表示代码段的对齐方式,为4字节对齐。
.type:表示数据的类型,这里的sleepsecs是一个全局变量且是强符号(已赋初值)。
.size:表示数据所占空间大小。这里的sleepsecs是一个整型数据,占4个字节。
接下来是关于全局变量sleepsecs的信息:
.long:表示sleepsecs的类型(其实应为int,但是int和long这两个类型的数据都是占4个字节的)。后面的数字2表示sleepsecs的初值,虽然源程序为sleepsecs赋值为2.5,但由于sleepsecs本身是int(long)类型,因此初值向下取整为2。
.section .rodata:只读数据段,这里是printf函数中的两个字符串常量。
.align(第二个):表示只读数据段的对齐方式,为8字节对齐。
.LC0 .LC1是两个只读的字符串常量,是printf函数输出的字符串。
.text:表示代码段。
.globl main:main是函数,也属于强符号。
.type:表示main是函数类型。
3.3.2 数据、赋值、算术操作与类型转换
hello.c程序共涉及6个数据,分别是:全局变量sleepsecs,main函数的参数整型数据argc和字符型指针的数组argv[],局部变量整型数据i,printf函数中的两个字符串常量。
其中,sleepsecs在.data节中保存初始值,两个字符串常量保存在.rodata节中。局部变量i的每一次赋值都在代码段中体现为数据传送。
sleepsecs和字符串常量的相关内容已在3.3.1中体现,这里对局部变量i做一下说明。涉及到对i操作的是源代码中的for循环,汇编代码中的.L2、.L4和.L3部分,内容如下:
图3.3 循环体
.L2是对i第一次赋初值,i为int型,占4个字节,保存在寄存器%ebp中,地址为%rbp-4,指令为movl。然后执行循环体,也就是.L3和.L4的内容。.L3第一行判断条件是否进入循环体。执行完一次,我们看到.L4的最后一行对%ebp中的值进行加1操作。然后继续重复上述操作,直到不满足循环条件,跳出循环体,继续执行下面的代码。局部变量i也是唯一涉及到计算的数据。
在伪指令的部分,我们就已经注意到,hello.c源代码中,对sleepsecs赋值为2.5,但实际上数据段保存的sleepsecs的值为2。这里涉及到了隐式类型转换,将double型的2.5转换成了int(long)型的2,并且是向0舍入的。
3.3.3 逻辑/位操作:并未涉及
3.3.4 关系操作
共涉及两处,第一处是对argc!=3的判断,第二次是对i<10的判断。
图3.4 主函数
这里参数argc是保存在寄存器%edi中的,通过movl(传送双字)指令传送到%rbp-20的地址,再与3进行比较。
对i与10的比较可见图3.3中的.L3部分,第一行即为比较。
3.3.5 数组/指针/结构操作
hello.c只涉及一次数组操作,是main的第二个参数argv[]。argv[]本身的元素就是指针,是字符串的首地址。
仍然看图3.4中的main函数,argv[](实际上是数组argv的首地址)保存在寄存器%rsi中,通过movq(传送四字)指令传送到%rbp-32的地址。
图3.3中显示,.L4中,movq -32(%rbp), %rax指令将%rbp-32处的值(数组argv[]的首地址)传给寄存器%rax,addq $16, %rax指令为%rax中的值加16,得到argv[2]的地址,movq (%rax), %rdx指令将%rax中的地址的内容(argv[2]的内容,是一个字符型变量的地址)保存在寄存器%rdx中。接下来的四行指令进行的是相似的操作,不同之处在于,这次传送argv[1]的内容给寄存器%rsi。这样,argv[2]和argv[1]就可以作为printf的参数。
3.3.6 控制转移
程序涉及两处控制转移,第一处是比较argc和3,若相等则跳转到.L3处,若不相等则继续执行:
cmpl $3, -20(%rbp)
je .L2
第二处是比较循环变量i和9,若大于则跳出循环体,否则继续执行循环体:
cmpl $9, -4(%rbp)
jle .L4
3.3.7 函数操作
函数调用共有三处:
1.
图3.5 调用puts函数
这里参数是.LC0处的字符串常量,通过寄存器%rdi传给puts函数。
2.
图3.6 调用printf函数
这里参数是.LC1处的字符串常量、argv[1]和argv[2],通过寄存器%rdi、%rsi和%rdx传给printf函数。
3.
图3.7 调用sleep函数
这里参数是全局变量sleepsecs,通过寄存器%edi传给sleep函数。
汇编程序中未涉及从调用函数返回的部分。
3.4 本章小结
本章主要是关于hello.s的内容分析,包括各种操作的含义。这里体现了从C语言文件到汇编语言文件的变化过程,体现了C语句和汇编语句的映射关系。
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序,并将结果保存在目标文件hello.o中。hello.o是一个二进制文件,它包含的是函数main的指令编码。
4.2 在Ubuntu下汇编的命令
图4.1 汇编
其中hello.o就是经汇编得到的二进制文件。
4.3 可重定位目标elf格式
图4.2 可重定位目标文件的ELF头
ELF头中Magic序列描述了生成该文件的系统的字的大小和字节顺序。剩下的部分是帮助链接器语法分析和解释目标文件的信息。
图4.3 可重定位目标文件的节头部表
节头部表中是各个节的名称、大小、对齐方式、地址和偏移量等信息。
图4.4 重定位节.rela.text
这部分是.text节中位置的列表。
这部分每一行对应一个结构体
typedef struct{
long offset;
long type:32,
symbol:32;
long addend;
}Elf64_Rela;
偏移量:对应offset,需要被修改的引用的节偏移。
信息和类型:对应type,告知链接器如何修改新的引用,其中最后一位是2的对应重定位类型R_X86_64_PC32(重定位一个使用32位PC相对地址的引用),最后一位是4的对应重定位类型R_X86_64_PLT32。
符号值和符号名称:对应symbol,标识被修改引用应该指向的符号。
加数:对应addend,一些类型的重定位要使用它对被修改引用的值做偏移调整。
图4.5 重定位节.rela.eh_frame
与.rela.text节内容类似,是.eh_frame节中位置的列表,其他信息一致。
图4.6 符号表
符号表,内容为在程序中定义和引用的函数和全局变量的信息。
4.4 Hello.o的结果解析
图4.7 重定位前的反汇编代码
注意到涉及重定位的内容已经全部设置为0,并且在该行指令后紧跟的是如何修改这些设为0的内容的相关信息,包括重定位类型和应改为的内容。
数据引用和函数调用通过PC相对寻址,两个字符串常量通过.rodata,全局变量和函数通过符号表。反汇编代码中不再出现类似.LC0、.LC1的助记符。
4.5 本章小结
在这一章分析了可重定位目标文件的内容和反汇编代码与汇编代码的区别,在链接之前,相关数据都置为0,在链接时才将这些数据修改为实际的地址。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程。本章中,链接执行于编译时。
5.2 在Ubuntu下链接的命令
图5.1 链接
5.3 可执行目标文件hello的格式
图5.2 可执行目标文件的ELF头
我们看到hello已是一个可执行文件,入口地址为0x400500,其他信息没有变化。
图5.3 可执行目标文件的节头部表
链接后,节头部表中各节的地址已修改为重定位后的实际地址。我们可以看到.text节的地址是0x400500,与入口地址一致。其他各节也已修改,程序可以对全局变量、只读数据和函数进行引用或调用。
5.4 hello的虚拟地址空间
通过地址和偏移量,我们得到程序的起始位置为0x400000,数据的起始位置为0x600000。
图5.4 只读代码段
这部分就是.text、.fini、.rodata、.eh_frame的内容。
图5.5 数据段
.data节的内容,只有一个初始值为2的全局变量sleepsecs,占8字节。这是由.data节的对齐方式(4字对齐)决定的。
5.5 链接的重定位过程分析
首先,hello中没有.rela.text节、.bss节、.note.GNU-stack节、.rela.eh_frame节。相对地,增加了以下这些节:
.interp:保存ld.so的路径
.note.ABI-tag:Linux下特有的节
.hash:符号的哈希表
.gnu.hash:GNU拓展的符号的哈希表
.dynsym:运行时/动态符号表
.dynstr:存放.dynsym节中的符号名称
.gnu.version:符号版本
.gnu.version_r:符号引用版本
.rela.dyn:运行时/动态重定位表
.rela.plt:.plt节的重定位条目
.init:程序初始化需要执行的代码
.plt:动态链接-过程链接表
.fini:当程序正常终止时需要执行的代码
.dynamic:存放被ld.so使用的动态链接信息
.got:动态链接-全局偏移量表-存放变量
.got.plt:动态链接-全局偏移量表-存放函数
另外,.data节虽然仍然存在,但内容已变成已初始化的数据。
关于函数调用和数据引用部分,有如下变化:
首先是.init节中的_init函数,这个函数在初始化代码时调用,然后跳转到程序入口。
图5.6 .init节
接下来是程序执行过程中调用的库函数:
图5.7 标准库函数
这里可以看到,第一次调用函数的时候,都是进入过程链接表PLT中对应的位置,修改了全局偏移量表GOT之后,就可以调用函数了,此后对函数的调用就可以直接进入函数了。
接下来是_start函数:
图5.8 _start函数
可以看到,_start函数中包含对函数__libc_start_main的调用,这是一个标准库函数,_start函数通过它调用main函数,开始程序的执行。
在main函数中,我们看到hello.o中call指令的内容已被修改为对函数的调用,这种修改是基于原call指令后,对重定位类型的声明。
图5.9 重定位后的main函数
接下来是对.rodata的引用(.rodata是通过PC相对寻址进行引用的):
图5.10 对.rodata的引用
与helloo.txt进行对比:
图5.11 helloo.txt对应内容
注意到这里的指令已被修改为PC相对寻址的偏移量。
5.6 hello的执行流程
图5.12 程序执行过程中调用的子程序名及地址
5.7 Hello的动态链接分析
从前面的内容我们知道.got.plt节(该节内容为全局偏移量表GOT)是从地址0x601000开始的。在dl_init调用前,这部分内容为:
图5.13 调用dl_init前的.got.plt节
调用后内容变为:
图5.14 调用dl_init后的.got.plt节
发生变化的是地址0x601008到0x601017部分的内容,即全局偏移量表GOT[1]和GOT[2]的内容。GOT[1]保存的是一个地址,指向已经加载的共享库的链表地址。GOT[2]是动态链接器在ld-linux.so模块中的入口点。这样,接下来执行程序的过程中,就可以使用过程链接表PLT和全局偏移量表GOT进行动态链接了。
5.8 本章小结
本章主要是对hello.o和标准库的动态链接过程的分析,涉及到动态链接和位置无关代码的相关知识。在链接的过程中,代码和数据可以得到一个确定的虚拟地址空间,函数调用指令被修改为函数的真实地址的偏移量。为了实现位置无关代码的链接,在代码初始化阶段,修改GOT的内容,使得接下来的过程中可以通过PLT实现动态链接。此后,只需要在第一次调用函数时确定函数的位置,通过PLT和修改GOT进行链接,而之后的调用就不必再进行链接,这样可以节省内存空间。至此,链接过程完成。
第6章 hello进程管理
6.1 进程的概念与作用
进程的经典定义是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
进程提供给应用程序两个关键抽象:
1.一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
2.一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个交互型的应用级程序,它代表用户运行其他程序。
shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
求值步骤:
1.调用parseline函数解析以空格分隔的命令行参数,构造argv向量传递给execve;
2.调用builtin_command函数,检查第一个命令行参数是否是一个内置的shell命令,如果是,它就立即解释这个命令,并返回值1;
3.如果builtin_command函数返回0,那么shell创建一个子进程,并在子进程中执行所请求的程序。如果用户要求在后台运行该程序,那么shell返回到循环的顶部,等待下一个命令行。否则,shell使用waitpid函数等待作业终止。当作业终止时,shell就开始下一轮迭代。
6.3 Hello的fork进程创建过程
在终端输入./hello,由于这不是一个内置命令,builtin_command函数返回0,shell使用fork函数创建子进程,在子进程中执行所请求的程序hello。
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
fork具有以下特点:
1.调用一次,返回两次。fork函数被父进程调用一次,但是却返回两次——一次是返回到父进程(返回子进程的PID),一次是返回到新创建的子进程(返回0)。
2.并发执行。父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令。
3.相同但是独立的地址空间。如果能够在fork函数在父进程和子进程中返回后立即暂停这两个进程,我们会看到两个进程的地址空间都是想通过的。每个进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。然而,因为父进程和子进程是独立的进程,它们都有自己的私有地址空间,它们对本地变量所做的任何改变都是独立的,不会反映在另一个进程的内存中。
4.共享文件。子进程继承父进程所有的打开文件。
本例中,进程图为:
图6.1 进程图
6.4 Hello的execve过程
execve函数的原型为:
int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载并运行可执行目标文件filename(在这里是hello),且带参数列表argv和环境变量envp。只有当出现错误时,execve才会返回到调用程序。
execve的参数中,argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串。argv[0]是可执行目标文件的名字。envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name=value”的名字-值对。
在execve加载了hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型:
int main(int argc, char **argv, char **envp);
或者等价的
int main(int argc, char *argv[], char *envp[]);
当main开始执行时,用户栈的组织结构如图所示。
图6.2 用户栈
从栈底(高地址)往栈顶(低地址),依次为:
参数和环境字符串。
以null结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串。全局变量environ指向这些指针中的第一个envp[0]。
以null结尾的argv[]数组,其中每个元素都指向栈中的一个参数字符串。
系统启动函数libc_start_main(栈顶)。
main函数的三个参数依次为:
argc,argv[]数组中非空指针的数量。
argv,指向argv[]数组中的第一个条目。
envp,指向envp[]数组中的第一个条目。
execve函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。
6.5 Hello的进程执行
hello程序执行,进入循环体:
图6.3 源程序hello.c中的循环体内容
执行到sleep(sleepsecs)语句时(在此之前这个进程也有可能被抢占,此处假设其未被抢占),内核就可以抢占这个进程(下面称其为A),并调度一个新的进程。这里内核就使用了上下文切换机制:保存当前进程的上下文,运行新的进程(下面称其为B)。
前面提到,由于隐式类型转换,sleepsecs的值为2。这样,如果进程没有收到信号,它会在挂起A2秒后,发送一个信号,重新调度A。这时,内核保存B的上下文,恢复A的上下文,重新将控制传递给A。
最后在getchar处发生中断,读入一个字符后返回到下一条指令,继续运行直到程序结束。
此处假设一种简单的情况:在sleep函数调用之前,A不被抢占,且只有两个进程A和B并发运行。这样,逻辑控制流和时间分片如下图所示:
图6.4 逻辑控制流的转移和时间分片
上下文切换的过程:
图6.5 上下文切换和用户模式与内核模式的切换
6.6 hello的异常与信号处理
1.正常运行
sleep函数调用时发送信号,内核挂起进程A并调度进程B,直到两秒后返回,再次发送信号,内存挂起进程B,调度进程A。
getchar函数调用时发送信号,内核挂起进程A并调度进程B,直到从键盘输入的字符加载到内存,再次发送信号,内存挂起进程B,调度进程A。
这是最简单的情况,没有其他的信号向进程A发送。执行完成后,进程A被回收,发送SIGCHLD信号。
图6.6 键入回车前的输出内容
图6.7键入回车,进程A终止并被回收
(getchar函数是在键入回车后才从缓冲区读入字符的,所以最后这里可以乱按,只要保证最后有回车就可以了)
2.程序执行时通过键盘输入Ctrl+C发送信号SIGINT
图6.8 用Ctrl+C发送信号
在输出第四行后,输入Ctrl+C,发送信号SIGINT给前台进程组,结果是进程A终止并被父进程回收。
图6.9 发送信号后
这时hello进程已被回收,也没有其他的前台进程。
3.程序执行时通过键盘输入Ctrl+Z发送信号SIGTSTP
图6.10 用Ctrl+Z发送信号
在输出第四行后,输入Ctrl+Z,发送信号SIGTSTP给前台进程组,结果是进程A被挂起。
若使用fg将进程A在前台运行,结果为:
图6.11 继续运行进程A
fg 1向JID为1的进程发送信号SIGCONT,使其在前台继续运行,若不再发送其他信号,则正常运行直至终止被回收。
重复上面的操作,不同的是,这次用命令kill发送信号:
图6.12 终止挂起的进程A
向PID为3589的进程(进程A)发送了一个SIGKILL信号,该进程终止并被回收。
4.程序执行时乱按键盘
图6.13 乱按
可以看到,这些字符不能向进程发送信号,只是保存在缓冲区内,直到最后调用getchar时,读取一个以回车结尾的字符串后,进程终止并被回收。
6.7本章小结
本章介绍了进程的创建和执行过程、多任务、逻辑控制流的交错执行、异常处理和信号处理的相关内容。
第7章 hello的存储管理
7.1 hello的存储器地址空间
物理地址:计算机系统的主存被组织成一个有M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。第一个字节的地址为0,接下来的字节地址为1,在下一个为2,依此类推。
虚拟地址:使用虚拟寻址,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送到内存之前先转换成适当的物理地址。CPU芯片上的内存管理单元利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容有操作系统管理。
链接后的可执行文件中,各个节的位置就是运行时它们在主存中的虚拟地址。
逻辑地址:在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。逻辑地址由两个16位的地址分量构成,一个为段基值,另一个为偏移量。两个分量均为无符号数编码。
线性地址:线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符和段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。索引号,可以理解为数组的下标——而它将会对应一个数组,它又是什么的索引呢?这就是“段描述符(segment descriptor)”,段描述符具体地址描述了一个段(对于“段”这个字眼的理解:我们可以理解为把虚拟内存分为一个一个的段。比如一个存储器有1024个字节,可以把它分成4段,每段有256个字节)。这样,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
7.3 Hello的线性地址到物理地址的变换-页式管理
内存管理单元MMU中的地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。
页表是一个页表条目PTE的数组。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。每个PTE有一个有效位和一个n位地址字段组成。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是利用VPN的位进行虚拟寻址的。VPN的较低4位作为组索引,较高32位作为标记,用来区别可能映射到同一个TLB组的不同的VPN。如果TLB命中,MMU直接从TLB中取出相应的PTE并翻译成物理地址。
如果不命中,MMU就需要从L1缓存中取出相应的PTE,然后存放在TLB中。
图7.1 四级页表
虚拟地址被划分为4个VPN和1个VPO,VPN i是一个到第i级页表的索引(i=1, 2, 3, 4),第j级页表中的每个PTE(j=1, 2, 3),指向第j+1级的某个页表的基址。
7.5 三级Cache支持下的物理内存访问
虚拟地址已经被翻译为物理地址,接下来要做的就是在高速缓存中读取相应的内容。以L1高速缓存为例。Intel Core i7的L1高速缓存块大小为64字节,8路组相联,组数为64。这样,52位的物理地址包含6位CO,6位CI以及40位CT。物理地址在L1中可能发生不命中,此时需要在L2中寻找对应的行,可能替换L1中的行。Core i7采用的替换策略为最不常使用(LFU)策略。若在L2中也发生不命中,则按照L3、主存的顺序依次进行替换即可。
图7.2 物理内存访问
7.6 hello进程fork时的内存映射
图7.3 进程地址空间
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当着两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
图7.4 加载器是如何映射用户地址空间的区域的
execve函数加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域。hello程序与标准C库链接,这些对象动态链接到这个程序,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器。execve做得最后一件事情是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
第1步:处理器生成一个虚拟地址,并把它传送给MMU。
第2步:MMU生成PTE地址,并从高速缓存/主存请求得到它。
第3步:高速缓存/主存向MMU返回PTE。
第4步:PTE中的有效位是零,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
第5步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。
第:6步:缺页处理程序页面调入新的页面,并更新内存中的PTE。
第7步:缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,主存将所请求字返回给处理器。
图7.5 缺页异常处理
7.9动态存储分配管理
先来看看堆的结构:
图7.6 堆
printf函数中使用malloc函数和free函数来显式地分配堆块,采用启发式策略来试图维持少量的大空闲块,使用隐式空闲链表搜索空闲块,采用带边界标记的合并。
1.分配和释放
malloc函数原型为:void *malloc(size_t size);
它返回一个指针,指向大小为至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。在64位模式中,该地址总是16的倍数。如果malloc遇到问题,那么它就返回NULL,并设置errno。malloc不初始化它返回的内存。如果没有足够大的空闲块,可以扩展堆,在扩展后的堆中找到空闲块。
free函数原型为:void free(void *ptr);
ptr参数必须指向一个从malloc、calloc或者realloc获得的已分配块的起始位置。free函数将这个块最低位设为0(空闲)。
2.合并空闲块
图7.7 使用边界标记的堆块的格式
可以看到,加了脚部以后,分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态。已分配的块甚至可以不需要脚部,这时它的已分配/空闲位可以存放在后面一个块头部的多出来的低位中。
于是我们有如下的合并方法:
图7.8 使用边界标记的合并
3.空闲链表
将堆组织为一个连续的已分配块和空闲块的序列,这种结构称为隐式空闲链表。空闲块通过头部中的大小字段隐含地连接着。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。
一种更好的方法是将空闲块组织为某种形式的显式数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred和succ指针。如果用后进先出的顺序维护链表,将新释放的块放置在链表的开始处,释放一个块可以在常数时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。另一种方法是按照地址顺序来维护链表。
7.10本章小结
本章介绍了从虚拟地址到物理地址的翻译,存储器层次结构,高速缓存策略,缺页异常处理,以及虚拟内存的显式分配。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前的文件位置、读写文件、关闭文件。
8.2 简述Unix IO接口及其函数
1.int open(char *filename, int flags, mode_t mode);
open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符。
flags参数指明了进程打算如何访问这个文件。
mode参数指定了新文件的访问权限位。
2.int close(fd);
fd是需要关闭的文件的描述符,close返回操作结果,关闭一个已关闭的描述符会出错。
3.ssize_t read(int fd,void *buf,size_t n);
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。
返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
4.ssize_t wirte(int fd,const void *buf,size_t n);
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
研究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;
}
在形参列表里有这么一个token:… 这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。
再来看看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);
}
printf接受一个格式化的命令,并把指定的匹配的参数格式化输出。i = vsprintf(buf, fmt, arg);vsprintf返回的是打印出来的字符串的长度。printf中后面的一句:write(buf, i);调用系统函数write,把buf中的i个元素的值写到终端。
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里是给几个寄存器传递了几个参数,然后一个int结束。这样的int表示要调用中断门了。通过中断门,来实现特定的系统服务。int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
sys_call的实现:
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
sys_call的功能是显示格式化了的字符串。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar调用系统函数read,发送一个中断信号,内核抢占这个进程,用户输入字符串,键入回车后(字符串和回车都保存在缓冲区内),再次发送信号,内核重新调度这个进程,getchar从缓冲区读入字符。
8.5本章小结
本章主要是对printf和getchar两个函数的分析,略微涉及一些信号处理的内容。I/O设备可模型化为文件,read和write函数就可以看成是对文件的操作。实际的输入和输出还需要借助输入输出设备的技术,但这不在这门课程的讨论范围内。另外注意到,带缓冲的输入函数需要以回车作为结束标志,这样它们才可以开始读取输入的内容。
结论
1.预处理,这个步骤是将预处理命令进行“翻译”,包括条件编译语句的处理将头文件的内容写在程序里,以及宏定义的替换。
2.编译,将.i文件编译成汇编语言文件。对于不同的高级语言源程序,使用同样的汇编语言,从而保证下一步汇编的顺利进行。
3.汇编,生成可重定位目标文件。计算机只能识别二进制数据。为了节省内存空间,我们把几部分代码分别放在不同的文件里,并在运行时进行动态链接。
4.链接,将可重定位目标文件中各个段的地址修改为运行时的实际虚拟地址,与标准库进行链接,使程序可以被加载到内存中运行。
5.运行,shell创建子进程,程序加载到内存,CPU通过MMU翻译虚拟地址,读写数据,期间涉及到高速缓存的加载和替换。标准输入输出函数通过中断和信号处理,显示字符串,读入用户输入的字符。运行过程中,还可以从键盘发送信号,结果可以是进程的挂起或终止。
对于hello从C语言源程序到运行完成的过程的分析,使我更深入地了解了现代计算机系统的各种策略。从二进制码到高级语言的抽象,从物理寻址到虚拟内存,以及时间、空间性能优化的各种策略、方法的提出,使计算机科学发展到今天已经成为一门高深的学问。这里面的成就离不开这些基本的知识。同时,也可能为我们提供一些新的编程思路,以进一步提升程序的时间和空间性能。至此,hello的一生分析结束。但是我们的学习之路不会到达尽头。
附件
hello.i:包含预处理命令经预处理器修改后的内容,即将stdio.h,unistd.h,stdlib.h三个头文件的内容插入到hello.c程序后的内容。
hello.s:hello.c编译后得到的汇编语言程序。汇编语言为不同高级语言的不同编译器提供了通用的输出语言,可以接着汇编成机器指令。
hello.o:可重定位目标程序,区别于hello.c,hello.i,hello.s这些文本文件,hello.o是一个二进制文件,内容是函数main的机器指令,它可以和C库函数的可重定位目标文件链接得到可执行文件。
helloo.txt:hello.o链接前的反汇编代码,用于与hello.s进行比较。
hello:链接后得到的可执行文件,可以加载到内存中由系统执行。
hello.elf:hello.o链接后的elf文件,帮助分析链接过程。
hello.txt:hello.o链接后的反汇编代码,用于与helloo.txt比较并分析链接过程。
(附件0分,缺失 -1分)
参考文献
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.