摘 要
hello.c,从一个源程序到到可执行程序需要经过:预处理器(cpp)生成hello.i即被扩充的源程序(文本);编译器(ccl)生成hello.s即汇编程序(文本);汇编器(as)生成hello.o即可重定位目标程序(二进制);链接器(ld)生成hello可执行目标程序(二进制)。历经艰辛,Hello —— 一个完美的生命诞生了。
关键词:预处理、编译、汇编、链接、Bash、OS、MMU、
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 5 -
2.1 预处理的概念与作用 - 5 -
2.2在Ubuntu下预处理的命令 - 5 -
2.3 Hello的预处理结果解析 - 5 -
2.4 本章小结 - 5 -
第3章 编译 - 6 -
3.1 编译的概念与作用 - 6 -
3.2 在Ubuntu下编译的命令 - 6 -
3.3 Hello的编译结果解析 - 6 -
3.4 本章小结 - 6 -
第4章 汇编 - 7 -
4.1 汇编的概念与作用 - 7 -
4.2 在Ubuntu下汇编的命令 - 7 -
4.3 可重定位目标elf格式 - 7 -
4.4 Hello.o的结果解析 - 7 -
4.5 本章小结 - 7 -
第5章 链接 - 8 -
5.1 链接的概念与作用 - 8 -
5.2 在Ubuntu下链接的命令 - 8 -
5.3 可执行目标文件hello的格式 - 8 -
5.4 hello的虚拟地址空间 - 8 -
5.5 链接的重定位过程分析 - 8 -
5.6 hello的执行流程 - 8 -
5.7 Hello的动态链接分析 - 8 -
5.8 本章小结 - 9 -
第6章 hello进程管理 - 10 -
6.1 进程的概念与作用 - 10 -
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
6.3 Hello的fork进程创建过程 - 10 -
6.4 Hello的execve过程 - 10 -
6.5 Hello的进程执行 - 10 -
6.6 hello的异常与信号处理 - 10 -
6.7本章小结 - 10 -
第7章 hello的存储管理 - 11 -
7.1 hello的存储器地址空间 - 11 -
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 -
7.9动态存储分配管理 - 11 -
7.10本章小结 - 12 -
第8章 hello的IO管理 - 13 -
8.1 Linux的IO设备管理方法 - 13 -
8.2 简述Unix IO接口及其函数 - 13 -
8.3 printf的实现分析 - 13 -
8.4 getchar的实现分析 - 13 -
8.5本章小结 - 13 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -
第1章 概述
1.1 Hello简介
P2P:hello.c通过预处理器、编译器、汇编器、链接器,执行预处理→编译→汇编→链接一系列操作,生成了hello可执行文件。通过fork()创建子进程,并通过exceve()加载,之后执行:磁盘读取、虚拟内存映射、CPU执行指令、内核调度、缓存加载数据、信号处理、Unix I/O输入与输出。最后进程终止,shell与内核对其进行回收。
O2O:hello.c,先后经过了hello.i,hello.s,hello.o最终到可执行文件hello。执行完成后回收hello进程,hello从内核中删除。
1.2 环境与工具
硬件环境:Intel Core i7-8750H,16GB RAM,512GB SSD
软件环境:Windows10 64位; Vmwareworkstatio15pro;Ubuntu 18.04 LTS 64位
开发工具: CodeBlocks 64位;vim;gcc;readelf;gdb;
1.3 中间结果
hello.c 原始c程序(源程序)
hello.i 预处理操作后生成的文本文件
hello.s 编译之后生成的汇编语言文件
hello.o 汇编之后生成的可重定位文件
hello 链接之后生成的可执行程序
1.4 本章小结
本章通过对hello的P2P和O2O介绍,初步了解了hello的一生,简单介绍了实验所用的软硬件环境以及开发工具,最后列举了hello处理过程中的一系列中间产物。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
1.概念
预处理是指预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,比如#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。其他常见的预处理指令还有#define(定义宏)、#if、#ifdef、#endif等。预处理的结果就得到了另外一个C程序,通常是以.i作为文件扩展名。
2.作用
预处理实现了在编译器进行编译之前对源代码做某些转换。例如将头文件中的代码插入到程序代码中、通过宏对符号进行替换等。
2.2在Ubuntu下预处理的命令
图2.1Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
针对#开头的语句,CPP 从系统库中获取 stdio.h,unistd.h,stdlib.h,并添加文本到当前的源文件中。通过词法分析,进行简单的词法单元替换,扩充了变量类型,函数,常量等。
图2.2.1 Hello的预处理结果1
图2.2.2 Hello的预处理结果2
2.4 本章小结
本章分析了hello.c的预处理阶段,根据预处理命令得到了hello.i程序,C预处理器扩展源代码,插入所有用#include指定的文件,并扩展所有用#define定义的宏
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
1.概念
编译是指编译器(ccl)将文本文件hello.i翻译成文本文件hello.s的过程,这个文本文件内包含了一个汇编语言程序。
2.作用
编译过程编译器实现了经过词法分析、语法分析、语义分析等过程,在检查无错误后将代码翻译成汇编语言。得到的汇编语言代码可供编译器进行生成机器代码、链接等操作。由于计算机并不能直接接受和执行用高级语言编写的源程序(此处指.c,.i文件),因而利用编译器,能将高级语言编写的程序全盘扫描,翻译成用机器语言表示的与之等价的目标程序(此处指.s,即汇编语言程序),该目标程序能被计算机接受和执行,以便后续翻译等操作进行。
3.2 在Ubuntu下编译的命令
图3.1Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.3.1汇编指令
.file 声明源文件
.text 以下是代码段
.section .rodata 以下是rodata节
.globl 声明一个全局变量
.type 用来指定是函数类型或是对象类型
.size 声明大小
.long、.string 声明一个long、string类型
.align 声明对指令或者数据的存放地址进行对齐的方式
3.3.2常量
字符串:“Usage: Hello 学号 姓名! \n”,在.LC0中声明
图3.3.1字符串在hello.s中的声明
整数:int sleepsecs=2.5,sleepsecs被声明为object,即全局变量,设置类型为对象、设置大小为 4 字节、设置为 long 类型其值为 2,存储在.rodata中。
图3.3.2sleepsec在.rodata中声明
3.3.3变量(全局/局部/静态)
int argc,char *argv[],参数argv 指针指向已经分配好的、一片存放着字符指针的连续空间,起始地址为 argv,main函数中访问数组元素时,按照起始地址 argv大小 8B 计算数据地址取数据
图3.3.3argv地址
int i,局部变量,占用了4字节,存储在栈中
图3.3.4局部变量i地址
3.3.4赋值
sleepsecs 是全局变量,在.data 节中将 sleepsecs 声明为值 2 的 long 类型数据
i初值为1
图3.3.5局部变量i的赋值
每隔一个循环i的值加1
图3.3.6i的循环赋值
3.3.5类型转化
sleepsecs类型为int,在赋值为2.5时自动转化为2
3.3.6算数操作
栈的增减
图3.3.7栈变化
算数增减
图3.3.8算数运算
3.3.7关系操作
比较
图3.3.9比较
跳转
图3.3.10跳转
3.3.8数组/指针/结构操作
将数组argv[]转移到寄存器edi,rsi中。
图3.3.11数组加载
3.3.9控制转移
movl $0, -4(%rbp) jmp .L3 cmpl $9, -4(%rbp) jle .L4
addl $1, -4(%rbp) 表示了for(i=0;i<10;i++)
图3.3.12跳转
cmpl $3, -20(%rbp) je .L2表示了if(argc!=3)
图3.3.13比较
3.3.10函数操作
1.main函数:
参数传递:传入参数argc和argv,分别用寄存器%rdi和%rsi存储。
函数调用:被系统启动函数调用。
函数返回:返回eax=0
2.printf函数:
参数传递:第一次 printf 将%rdi 设置为“Usage: Hello 学号 姓名! \n”字符串的首地址。第二次 printf 设置%rdi 为“Hello %s %s\n” 的首地址,设置%rsi 为 argv[1],%rdx 为 argv[2]。函数调用:for循环中被调用
3.exit函数:
参数传递:传入的参数为1,再执行退出命令
函数调用:if判断条件满足后被调用
4.sleep函数:
参数传递:将%edi 设置为 1。
函数调用:for循环下被调用
5.getchar
传递控制:call getchar
函数调用:在main中被调用
3.4 本章小结
汇编代码搭建了从高级语言到底层机器语言的桥梁,实现了对内存数据的各种操作。编译器通过语法分析、语义分析等编译C语言代码到汇编代码,为接下来生成机器代码奠定了基础。
(第3章2分
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编器(as)将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
汇编的作用:汇编操作将汇编语言程序转变为一个可重定位文件,该文件不是最终的可执行文件,但是该文件可以和一些静态连接库或者动态连接库链接共同加入到可执行文件中去,
4.2 在Ubuntu下汇编的命令
图4.2Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.3.1 ELF头:描述了生成该文件的系统的字的大小和字节顺序,帮助链接器语法分析和解释目标文件的信息。包括了ELF的类别,数据,版本,OS/ABI,ABI版本,类型系统架构等信息。
图4.3.1ELF头
4.3.2节头:包含了各个节名称,大小,地址,偏移量,大小等信息
图4.3.2节头
4.3.3重定位节:将所有相同类型的节点合并为同一个类型的新的聚合节,然后将运行时存储器地址赋给新的聚合节,从而赋给输入模块定义的每个节、每个符号。当这一步完成时,程序中每个指令和全局变量都有唯一的运行时存储器地址。
两种基本的重定位类型:
R_X86_64_PC32 :重定位一个使用32位PC相对地址的引用。
R_X86_64_32 :重定位一个使用32位PC绝对地址的引用。
图4.3.3:重定位节
4.3.4 Symbol表:存放在程序中定义和引用的函数和全局变量的信息。
图4.3.4Symble表
4.4 Hello.o的结果解析
区别如下:
1.分支转移:编译与反汇编得到代码都包含程序实现基本的汇编语言代码。编译生成的代码跳转目标通过例如je .L2表示;反汇编的代码通过一个地址表示,如je <main+0x2b>,反汇编的结果中包含了供对照的来自可重定位目标文件中的机器语言代码及相关注释,包括一些相对寻址的信息与重定位信息。
2.操作数:hello.s中的操作数为十进制,hello.o反汇编代码中的操作数为十六进制
3.函数调用:hello.s中,call指令之后直接是函数名称;由机器语言反汇编得到的代采取相对寻址,通常是下一条指令的地址加偏移量,从而得到函数的绝对地址
图4.4编译与反汇编代码比较
4.5 本章小结
本章从汇编的概念与作用出发,通过生成hello.o的elf文件,分析了解了其中各节内容。通过将hello.o获得反汇编代码与hello.s文件进行对比,发现反汇编后立即数表示,分支转移,函数调用的方式有所不同。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
1.概念
链接是指将文件中调用的各种函数跟静态库及动态库链接,并将它们打包合并形成目标文件,即可执行文件。
2.作用
通过链接可以实现将头文件中引用的函数并入到程序中。
例如hello程序中用到的printf函数,该函数存在于一个名为printf.o的单独预编译目标文件,必须将其并入到hello.o的程序中,链接器实现的便是将两者并入,得到最终的hello文件内容。
5.2 在Ubuntu下链接的命令
图5.2Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
段头部表 将连续的文件节映射到运行时内存段
.init 程序初始化代码需要调用的函数
.text 已编译程序的机器代码
.rodata 只读数据
.data 已初始化的全局和静态C变量
.bss 未初始化的全局和静态C遍历
.symtab 存放程序中定义和引用的函数和全局变量信息
.debug 条目是局部变量、类型定义、全局变量及C源文件
.line C源程序中行号和.text节机器指令的映射
.strtab .symtab和.debug中符号表及节头部中节的名字
节头部表 描述目标文件的节
图5.33.1节头表
5.4 hello的虚拟地址空间
从5.3看到,虚拟内存地址通过初始地址加偏移量得到。比如,.init段的段偏移为0x488,虚拟内存开始的地方是0x4000000,通过IDA可以看到从0x4000488开始记录的就是.init节
图5.4.1init节
图5.4.2.plt节
图5.4.3.text节
图5.4.4.got节
图5.4.5.got.plt节
5.5 链接的重定位过程分析
链接前,main函数从0开始,相应函数应用没有具体函数名,通过%rip的相对偏移寻址也没有确定。
链接后,main的地址从0变为400532,库函数的名称与地址链接到了程序中,确定了通过%rip相对偏移寻址的偏移量。
重定位的实现依靠.rodata这个段,这个段保留重定位所需的信息,叫做重定位条目,链接器解析重定条目时发现两个类型为R_X86_64_PC32的对.rodata的重定位(printf中的两个字符串),.rodata与.text节之间的相对距离确定,因此链接器直接修改call之后的值为目标地址与下一条指令的地址之差,指向相应的字符串。
图5.5链接前代码
图5.5.2链接后代码
5.6 hello的执行流程
图5.6hello各执行程序
5.7 Hello的动态链接分析
_GLOBAL_OFFSET_TABLE_变化,被赋上了相应的偏移量的值。
图5.7.1.init执行前global.offset节
图5.7.2.init执行后global.offset节
5.8 本章小结
本章讨论了链接过程中对程序的处理,介绍了链接器如何将hello.o可重定向文件与动态库函数链接起来,分析了程序如何实现的动态库链接。链接后,程序便能够在作为进程通过虚拟内存机制直接运行。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
1.概念
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
2.作用
进程作为一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的。
这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
6.2 简述壳Shell-bash的作用与处理流程
shell作为父进程通过fork函数为hello创建一个新的进程,供其执行。通过fork函数,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本。
处理流程:shell 执行一系列的读/求值(read /evaluate ) 步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
6.3 Hello的fork进程创建过程
Shell通过调用fork 函数创建一个新的运行的子进程。也就是Hello程序,Hello进程几乎但不完全与Shell相同。Hello进程得到与Shell用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。Hello进程还获得与Shell任何打开文件描述符相同的副本,这就意味着当Shell调用fork 时,Hello可以读写Shell中打开的任何文件。Sehll和Hello进程之间最大的区别在于它们有不同的PID。
6.4 Hello的execve过程
创建进程后,在子进程中通过判断pid即fork()函数的返回值,判断处于子进程,则会通过execve函数在当前进程的上下文中加载并运行一个新程序。execve加载并运行可执行目标文件,且带参数列表argv和环境变量列表envp。只有当出现错误时,execve才会返回到调用程序。
在execve加载了可执行程序之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,即可执行程序的main函数。此时用户栈已经包含了命令行参数与环境变量,进入main函数后便开始逐步运行程序。
6.5 Hello的进程执行
逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
hello程序执行过程中存储时间分片,与操作系统的其他进行并发运行。并发执行涉及到操作系统内核采取的上下文交换策略。内核为每个进程维持一个上下文,上下文就是内核重新启动一个先前被抢占的进程所需的状态。
在执行过程中,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这个过程称为调度。
在此基础上,hello程序与操作系统其他进程通过操作系统的调度,切换上下文,拥有各自的时间片从而实现并发运行。
程序在涉及到一些操作时,例如调用一些系统函数,内核需要将当前状态从用户态切换到核心态,执行结束后再及时改用户态,从而保证系统的安全与稳定。
6.6 hello的异常与信号处理
异常类型:
中断是来自I/O设备的信号,异步发生,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。
信号类型:
SIGINT 终止进程 中断进程
SIGKILL 终止进程 杀死进程
SIGSTOP 停止进程 非终端来的停止信号
SIGCHLD 忽略信号 当子进程停止或退出时通知父进程
(1)ctrl+z:
图6.6.1按CTRL+Z后结果
(2)ps:
图6.6.2执行ps命令后结果
(3)jobs:
图6.6.3执行jobs命令后结果
(4)pstree:
图6.6.4执行pstree命令后结果
(5)kill:
图6.6.5执行kill命令后结果
6.7本章小结
本章介绍了进程的概念和作用,介绍程序在shell中如何通过fork函数及execve创建新的进程并执行。
进程运行过程中会遇到异常,异常分为中断、陷阱、故障和终止四类,通过异常处理程序进行对应的处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:又称相对地址,是程序运行由CPU产生的与段相关的偏移地址部分。他是描述一个程序运行段的地址。
物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。他是在前端总线上传输的而且是唯一的。
线性地址:这个和虚拟地址是同一个东西,是经过段机制转化之后用于描述程序分页信息的地址。他是对程序运行区块的一个抽象映射。
虚拟地址:CPU 通过生成一个虚拟地址(Virtual Address, VA) 。就是hello里面的虚拟内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段寄存器(16位),用于存放段选择符
CS(代码段):程序代码所在段
SS(栈段):栈区所在段
DS(数据段):全局静态数据区所在段
其他3个段寄存器ES、GS和FS可指向任意数据段
段选择符各字段含义:
图7.2.1段选择符字段
TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)
RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级
高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置
段描述符是一种数据结构,实际上就是段表项,分两类:
用户的代码段和数据段描述符
系统控制段描述符,又分两种:
特殊系统控制段描述符,包括:局部描述符表(LDT)描述符和任务状态段(TSS)描述符
控制转移类描述符,包括:调用门描述符、任务门描述符、中断门描述符和陷阱门描述符
描述符表实际上就是段表,由段描述符(段表项)组成。有三种类型
全局描述符表GDT:只有一个,用来存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及TSS(任务状态段)等都属于GDT中描述的段
局部描述符表LDT:存放某任务(即用户进程)专用的描述符
中断描述符表IDT:包含256个中断门、陷阱门和任务门描述符
图7.2.2Intel处理器的存储器寻址
段描述符的定义(IA32)
图7.2.3段描述符的定义
B31~B0: 32位基地址; L19~L0:20位限界,表示段中最大页号
G:粒度。G=1以页(4KB)为单位;G=0以字节为单位。因为界限为20位,故当G=0时最大的段为1MB;当G=1时,最大段为4KB×220 =4GB
D:D=1表示段内偏移量为32位宽,D=0表示段内偏移量为16位宽
P:P=1表示存在,P=0表示不存在。Linux总把P置1,不会以段为单位淘汰
DPL:访问段时对当前特权级的最低等级要求。因此,只有CPL为0(内核态)时才可访问DPL为0的段,任何进程都可访问DPL为3的段(0最高、3最低)
S:S=0系统控制描述符,S=1普通的代码段或数据段描述符
TYPE:段的访问权限或系统控制描述符类型
A:A=1已被访问过,A=0未被访问过。(通常A包含在TYPE字段中)
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU的页式内存管理单元,负责把一个线性地址,转换为物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。 另一类“页”,我们称之为物理页,或者是页框(frame)、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。 这里注意到,这个total_page数组有2^20个成员,每个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。如图:
图7.3页式管理
1、这样的二级模式是否仍能够表示4G的地址;页目录共有:210项,也就是说有这么多个页表每个目表对应了:210页;每个页中可寻址:212个字节。还是232 = 4GB
2、这样的二级模式是否真的节约了空间;也就是算一下页目录项和页表项共占空间 (2^10 * 4 + 2 ^10 *4) = 8KB。值得一提的是,虽然页目录和页表中的项,都是4个字节,32位,但是它们都只用高20位,低12位屏蔽为0,因为这样,它刚好和一个页面大小对应起来,大家都成整数增加。计算起来就方便多了。只要屏蔽其低10位就可以了,不过我想,因为12>10,这样,可以让页目录和页表使用相同的数据结构。
7.4 TLB与四级页表支持下的VA到PA的变换
36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1 提供到一个Ll PET 的偏移量,这个PTE 包含L2 页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推。如下图:
图7.4四级页表管理
7.5 三级Cache支持下的物理内存访问
计算机利用cache(高度缓存)来加快访存速度。它位于CPU与内存之间,访问速度比内存块很多,需要从内存里取数据时,先考虑是否在cache里有缓存。处理器对物理内存中数据的访问,同样需要经过缓存,即Cache,主流的处理器通常采用三级Cache。层与层之间按照以下原则进行读与写:
读取数据时,首先在高速缓存中查找所需字w的副本。如果命中,立即返回字w给CPU。如果不命中,从存储器层次结构中较低层次中取出包含字w的块,将这个块存储到某个高速缓存行中(可能会驱逐一个有效的行),然后返回字w。
地址翻译:(命中)
- 处理器生成一个虚拟地址,并将其传送给MMU
2-3) MMU 使用内存中的页表生成PTE地址 - MMU 将物理地址传送给高速缓存/主存
- 高速缓存/主存返回所请求的数据字给处理器
图7.5添加cache后地址翻译
7.6 hello进程fork时的内存映射
虚拟内存和内存映射解释了fork函数如何为每个新进程提供私有的虚拟地址空间.
为新进程创建虚拟内存
创建当前进程的的mm_struct, vm_area_struct和页表的原样副本.
两个进程中的每个页面都标记为只读
两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW)
在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存
随后的写操作通过写时复制机制创建新页面
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行新程序a.out的步骤:
删除已存在的用户区域
创建新的区域结构
私有的、写时复制
代码和初始化数据映射到.text和.data区(目标文件提供)
.bss和栈堆映射到匿名文件 ,栈堆的初始长度0
共享对象由动态链接映射到本进程共享区域
设置PC,指向代码区域的入口点
Linux根据需要换入代码和数据页面
图7.7execve 函数
7.8 缺页故障与缺页中断处理
地址翻译:(缺页异常)
- 处理器将虚拟地址发送给 MMU
2-3) MMU 使用内存中的页表生成PTE地址 - 有效位为零, 因此 MMU 触发缺页异常
- 缺页处理程序确定物理内存中牺牲页 (若页面被修改,则换出到磁盘)
- 缺页处理程序调入新的页面,并更新内存中的PTE
- 缺页处理程序返回到原来进程,再次执行缺页的指令
图7.8缺页异常处理
7.9动态存储分配管理
动态存储分配管理由动态内存分配器完成。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆是一个请求二进制零的区域,它紧接在未初始化的数据区后开始,并向上生长(向更高的地址)。分配器将堆视为一组不同大小的块的集合来维护。
每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显示地被应用程序所分配。
一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存分配器从堆中获得空间,将对应的块标记为已分配,回收时将堆标记为未分配。而分配和回收的过程中,往往涉及到分割、合并等操作。
动态内存分配器的目标是在对齐块的基础上,尽可能地提高吞吐率及空间占用率,即减少因为内存分配造成的碎片。其实现常见的数据结构有隐式空闲链表、显式空闲链表、分离空闲链表,常见的放置策略有首次适配、下一次适配和最佳适配。
Malloc示例;
图7.9malloc实例
7.10本章小结
操作系统通过通过段式管理在逻辑地址到虚拟地址,页式管理从虚拟地址到物理地址。
操作系统将主存抽象为虚拟内存,作为磁盘的缓存。在程序执行时从磁盘加载到主存,并将其主存的物理地址映射为虚拟地址,这样,便可以通过虚拟地址对主存进行访问,从而防止了各个进程之间的冲突与错误。操作系统通过MMU将虚拟地址转换为物理地址,利用TLB和多级页表提高其访问速度和内存利用率,从而实现对主存的有效访问。另外,在发生缺页时,操作系统通过信号处理程序能够很好的解决。
动态内存分配(Dynamic Memory Allocation)就是指在程序执行的过程中动态地分配或者回收存储空间的分配内存的方法。动态内存分配不象数组等静态内存分配方法那样需要预先分配存储空间,而是由系统根据程序的需要即时分配,且分配的大小就是程序要求的大小。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix I/O接口:Linux以文件的方式对I/O设备进行读写,将设备均映射为文件。对文件的操作,内核提供了一种简单、低级的应用接口。
Unix I/O接口提供了以下函数供应用程序调用:
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
8.3 printf的实现分析
Printf函数实现;
图8.3printf函数实现
其中*fmt是格式化用到的字符串,而后面省略的则是可变的形参,即printf(“%d”, i)中的i,对应于字符串里面的缺省内容。
va_start的作用是取到fmt中的第一个参数的地址,下面的write来自Unix I/O,而其中的vsprintf则是用来格式化的函数。这个函数的返回值是要打印出的字符串的长度,也就是write函数中的i。该函数会将printbuf根据fmt格式化字符和相应的参数进行格式化,产生格式化的输出,从而write能够打印。
在Linux下,write函数的第一个参数为fd,也就是描述符,而1代表的就是标准输出。查看write函数的汇编实现可以发现,它首先给寄存器传递了几个参数,然后调用syscall结束。write通过执行syscall指令实现了对系统服务的调用,从而使内核执行打印操作。
内核会通过字符显示子程序,根据传入的ASCII码到字模库读取字符对应的点阵,然后通过vram(显存)对字符串进行输出。显示芯片将按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最终实现printf中字符串在屏幕上的输出。
8.4 getchar的实现分析
Getchar实现:
图8.4getchar函数实现
getchar函数通过调用read函数返回字符。其中read函数的第一个参数是描述符fd,0代表标准输入。第二个参数输入内容的指针,这里也就是字符c的地址,最后一个参数是1,代表读入一个字符,符号getchar函数读一个字符的设定。read函数的返回值是读入的字符数,如果为1说明读入成功,那么直接返回字符,否则说明读到了buf的最后。
read函数同样通过sys_call中断来调用内核中的系统函数。键盘中断处理子程序会接受按键扫描码并将其转换为ASCII码后保存在缓冲区。然后read函数调用的系统函数可以对缓冲区ASCII码进行读取,直到接受回车键返回。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
输入/输出(I/O) 是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。Linux本身提供的一些系统函数已经实现了对底层的调用,例如write函数。printf函数正是通过它间接向标准输出这个文件输出内容,它会调用syscall触发中断以内核模式对硬件进行操作。
(第8章1分)
结论
Hello的一生有以下几个阶段:
生成阶段:
预处理:预处理器扩展源代码,插入所有用#include指定的文件,并扩展所有用#define定义的宏编译:把我们的C语言程序编译成汇编语言程序,生成.i文件
汇编:把汇编语言转换成机器代码,生成重定位信息,生成.o文件。
链接:与动态库链接,生成可执行文件hello
加载阶段:
在shell利用./hello运行hello程序,父进程通过fork函数为hello创建进程
通过加载器,调用execve函数,删除原来的进程内容,加载我们现在进程的代码,数据等到进程自己的虚拟内存空间。
执行阶段:
CPU取指令,顺序执行进程的逻辑控制流。这里CPU会给出一个虚拟地址,通过MMU从页表里得到物理地址, 在通过这个物理地址去cache或者内存里得到我们想要的信息
终止阶段:
程序执行结束后,父进程回收子进程,内核删除为这个进程创建的所有数据结构。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello.c 原始c程序(源程序)
hello.i 预处理操作后生成的文本文件
hello.s 编译之后生成的汇编语言文件
hello.o 汇编之后生成的可重定位文件
hello 链接之后生成的可执行程序
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] ida动态调试elf
https://www.52pojie.cn/thread-730499-1-1.html
[2] 可重定位目标文件解析 https://blog.csdn.net/qq_45139529/article/details/102839012
[3] 编译器百度百科 https://baike.baidu.com/item/%E7%BC%96%E8%AF%91%E5%99%A8/8853067?fr=aladdin
[4] C语言 目标文件和可执行文件 https://blog.csdn.net/qq_32534441/article/details/90242743
[5] Shell信号列表
https://blog.csdn.net/weixin_33795833/article/details/92683911
[6] 逻辑地址、线性地址、物理地址和虚拟地址理 解 https://blog.csdn.net/yuzaipiaofei/article/details/51219847
[7] 动态分配内存
https://baike.baidu.com/item/动态分配内存/2968252?fr=aladdin
(参考文献0分,缺失 -1分)