第1章 概述
1.1 Hello简介
通过键盘输入,使用高级语言得到hello.c源程序,然后.c文件通过cpp预处理为hello.i文件,再通过ccl编译为hello.s文件,再通过as将hello.s翻译成机器指令,生成hello.o文件,最后通过链接程序与函数库中的二进制文件生成hello可执行文件。在shell中输入启动命令后,shell为其fork一个子进程,分配内存资源,这样一步一步实现了from program to process (P2P)的过程。然后shell通过execve加载进程,CPU通过取址,译码,执行等操作处理文件,进入main函数执行代码。完成后shell通过父进程回收hello进程,回收内存,删除相应数据,实现了from zero-0 to zero-0(020)的过程,不带走一片云彩。
1.2 环境与工具列
1.2.1 硬件环境
X64 CPU;2.8GHz;8G RAM;1THD Disk;
1.2.2 软件环境
Windows10 64位;Vmware 14;Ubuntu 16.04 LTS 64位; 1
1.2.3 开发工具
Visual Studio 2015 64位;CodeBlocks;vi/vim/gpedit+gcc;
1.3 中间结果文件名作用
hello.c | 源代码 |
---|---|
hello.i | 预处理 |
hello.s | 编译后的代码 |
hello.o | 汇编后的代码 |
hello.out | 链接后的代码 |
hello | 可执行程序 |
hello.oobjdump | hello.o的反汇编代码 |
hello.objdump | 存放hello的反汇编代码 |
1.4 本章小结
本章主要概述了hello的一生,从hello.c源文件的生成,经过一步一步到达可执行程序,以及怎样通过shell加载运行,接下来会详细解析。
第2章 预处理
2.1 预处理的概念与作用
概念:在编译之前进行的处理。C语言的预处理主要有三个方面的内容:
1.宏定义;2.文件包含;3.条件编译。预处理命令以符号“#”开头。
- 宏定义又称为宏替换、宏代换,将定义的宏名替换为相应的字符串或实际值。
- 文件包含指一个文件包含另一个文件的内容。格式为#include<文件名>,被包含的文件称为头文件,头文件中含有函数原型,结构体等
- 条件编译格式为#if,当满足if后的条件时才去编译某些语句。
2.2在Ubuntu下预处理的命令
命令:cpp hello.c > hello.i
2.3 Hello的预处理结果解析
hello.c文件被预处理为hello.i文件,宏定义被展开,头文件包含在hello.i文件中,包含各种函数,定义的结构体等等(如下截图)打开hello.i文件后发现hello文件的长度明显增加,但仍为可以阅读的文本文件。
2.4 本章小结
本章主要讲述了预处理的定义、作用等,将hello.c文件预处理为hello.i文件,增加了许多内容,为下一步的编译阶段做好准备。
第3章 编译
3.1 编译的概念与作用
编译时利用编译程序(即编译器)从源语言编写的源程序产生目标程序的过程。把人们熟悉的语言转化为较为低级的语言。即将hello.i文件转化为hello.s文件。其工作阶段主要分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要进行的是词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误给出提示。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.c -o hello.s
3.3 Hello的编译结果解析
3.3.1 数据类型:
hello.c中所用的数据类型有字符串,整型数,数组。
字符串:
这条print语句通过编译器生成如图3.3.1第一条语句,字符串被编译为UTF-8格式(一个汉字占一个字节),而
print语句在hello.s中的声明如图3.3.1第二行语句所示。
整型数:hello.c中包含一个全局变量sleepsecs,由图3.3.2可以看出,sleepsecs经过编译存放在.rodata中,sleepsecs被赋值2.5,而被定义为int型,所以还会进行隐式转换,变为2。其他整型数,如int i,编译器将其存储在寄存器或者栈空间中,立即数直接进行编译,等等不再过多赘述。如图3.3.2
数组:hello.c程序中使用了整型数int argv数组char *argv作为主函数的参数,分别存放在了rbp寄存器-20和-32的位置。如图3.3.3所示,edi存放argc数组,rsi存放ragv数组。
3.3.2 条件控制
hello程序中有一条if语句,判断argv是否等于3,不等的话执行print语句,在hello.s中对应的语句如图3.3.4所示,cmpl 3和argv是否相等,相等跳转到.L2,不等执行puts函数打印相关字符串,然后调用exit函数结束程序。
3.3.3 循环结构
hello中有一处循环结构,用局部变量i<=9作为循环控制条件,i存放在-4(%rbp),首先先将i置为0,然后跳转到.L3,如果小于等于9,则跳转到.L4,进入循环体,首先进行取数操作,由于argv是指针数组,所以要进行二次寻址,前三行取argv[2],接着3行取argv[1],分别存放在rdx和rsi中,然后调用print函数打印相关信息,然后以全局变量sleepsecs为参数调用sleep函数,最后将i值加1,回到.L3判断是否继续进入循环,汇编代码如图3.3.5所示。
退出循环后,调用getchar()函数,并将返回值设为0,返回。
3.4 本章小结
本章主要讲述了编译的相关知识,实现了从hello.i到hello.s,给出了一些汇编指令和各种控制结构在汇编语言中的形式,通过对比c程序语言和相应的汇编语言可以掌握一些规律,读懂基本的汇编程序。编译工作为接下来的汇编打好基础。
第4章 汇编
4.1 汇编的概念与作用
汇编指的是把汇编语言翻译成机器语言的过程。由于汇编语言机器仍不能识别,需要转换为机器语言才能被机器识别。汇编语言指令与机器语言指令大体保持一一对应关系。通过汇编,将hello.s变为hello.o文件。
4.2 在Ubuntu下汇编的命令
命令:gcc hello.s -c -o hello.o
4.3 可重定位目标elf格式
用readelf -h hello.o获取hello.o的ELF格式 最开始为Magic,描述来声称该文件的系统的字的大小和字节顺序,可知该文件为重定位文件,剩下的内容主要包括ELF头的大小,目标文件的类型,机器类型,字节头部表的文件的偏移等信息。内容如图4.3.1
用readelf -S hello.o查看结头部表的信息,包含了文件中各个节的语义等信息。内容如下图4.3.2。由于是重定位目标文件,所以每个字节都从0开始。
用readelf -s hello.o查看符号表的信息,符号表用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号需要在其中声明。
4.4 Hello.o的结果解析
使用指令objdump -d -r hello,o得到如下的反汇编代码:
通过与hello.s的比较,发现主要流程结构并无不同,差别主要在以下
(1) .函数调用的不同:hello.s文件中直接通过call加函数名调用函数,而在机器语言call的地址是当前指令的下一条地址。
(2) 跳转指令的不同:.s文件中跳转指令使用的是短名称,例如jmp .L1,而在.o文件中,跳转指令用的是要跳转到的地址。
(3) 对栈的利用不同,反汇编语言中对栈的利用率极高,而.s文件中对栈存在一定浪费。 机器语言是二进制的机器指令序列,由0.1代码构成,机器指令由操作码和操作数组成。机器语言与汇编语言具有一一对应的映射关系,一条汇编语句对应一条机器语言语句,但移植性较差。
4.5 本章小结
本章主要实现了hello.s文件向hello.o文件的转变,本章通过对.o文件的反汇编和.s的汇编代码相比较,可以了解到二者之间的关系,不同的汇编程序可能对应相同的反汇编结果。通过汇编,为接下来的链接打好基础。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存中并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。在现代系统中,链接是由叫做链接器的程序自动执行的。它们使得分离编译成为可能。
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.3 可执行目标文件hello的格式
命令:readelf -a hello.o
分析ELF头部可知,该文件为可执行文件,共有25个节
分析上图的section headers,可以得到每个节的起始地址和偏移量。他们的起始地址分别对应着撞到虚拟内存中的虚拟地址,这样可以直接从文件起始处得到各段的起始位置,以及各段所占空间的大小。
5.4 hello的虚拟地址空间
使用edb加载hello得到的各段的虚拟地址的信息:
通过edb查看data dump得到的虚拟地址与程序头的虚拟地址相同。
5.5 链接的重定位过程分析
命令:objdump -d -r hello
通过与hello.o的反汇编结果比较可以发现,hello.o的各函数的虚拟地址均为0,而可执行程序的反汇编结果的虚拟地址都有了确定的值。同时增加了许多外来函数,增加了许多节,类似.init,.plt等等。
通过上面两个文件,可以了解到链接实现的过程:将可重定位文件中的.text节中函数以及全局变量的相对地址转变为了绝对地址,全局变量的寻址方式0x0%rsp也将0x0改为了确定的地址。
对hello.中的重定位节中的定位:在hello.o中,给出了函数和全局变量相对于EIF头的偏移量,所以在链接后,给定了程序首地址,然后根据偏移量,计算出函数和全局变量的绝对地址。
5.6 hello的执行流程执
行hello,经过以下子程序及其对应地址:
子程序名称 | 地址 |
---|---|
ld-2.27.so!_dl_start | 0xe8080e0000 |
ld-2.27.so!_dl_init | 0xe866f500 |
libc-2.27.so!_libc_start_main | 0xff15c60a20 |
libc-2.27.so!_cxat_atexit | 0xe834190200 |
hello!libc_csu_init | 0xffd5 |
hello!_init | 0xe897feffff |
libc-2.27.so!_setjmp | 0xe8c1d00100 |
libc-2.27.so!_sigjmp_save | 0xe908 |
hello!main@plt | 0xffd0 |
hello!puts@plt | 0xe85dffffff |
hello!exit@plt | 0xe883ffffff |
libc-2.27.so!@plt | E82e07faff |
*hello!print@plt | 0xe830 |
*hello!sleep@plt | 0xe853 |
*hello!getchar@plt | 0xe824 |
ld-2.27.so!_dl_fixup | 0xe82686ff |
ld-2.27.so!_dl_lookup_symbol_x | 0xe8edbl |
libc-2.27.so!exit | 0xe8b3fb0a |
5.7 Hello的动态链接分析
动态就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这是动态链接的基本思想。
5.8 本章小结
本章主要介绍了链接的概念与作用,重定位过程,从传统静态链接到加载时的共享库的动态链接,以及到运行时的共享库的动态链接等等。链接具有非常重要的作用,它使分离编译成为可能,把程序分为更小的更好管理的模块,可以独立的修改和编译这些模块,最后把他们链接到一起即可。通过链接,hello程序变为了可执行文件,即2进制文件,终于可以被机器识别运行了。
第6章 hello进程管理
6.1 进程的概念与作用概念:
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。进程让我们会得到一个假象,好像我们的程序在独占地使用处理器和内存。
作用:每次通过shell输入一个可执行文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行他们自己的代码或者其他应用程序,通过进程,系统可以实现多线程并发运行等操作。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell是一个交互型的应用程序,通过读取分析命令行的命令,代表用户运行其他程序。
处理流程:先读取命令行的命令,判断是否是内置命令,如果是,直接执行内置命令。如果不是,通过fork创建一个子进程,通过execve加载进程,执行可执行文件,并在结束后回收子进程。
6.3 Hello的fork进程创建过程
通过fork()创建子进程,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述相同的副本,意味着当父进程调用fork时,子进程可以读写父进程中的任何文件。父进程和子进程之间最大的差别在于他们有不同的PID。Fork被调用一次,但是会返回两次,一次是在调用进程中,一次是在新建的子进程中。在父进程中返回子进程的PID,在子进程中返回0.
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。execve函数加载并运行可执行文件filename,且带参数列表argv和环境变量列表envp,只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,execve调用一次并从不返回。加载filename后,调用启动代码,设置栈,并将控制传递给新程序的主函数,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。
6.5 Hello的进程执行
每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。当shell 运行一个程序时,父shell 进程生成一个子进程,它是父进程的一个复制。子进程通过execve 系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk), 新的代码和数据段袚初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main 函数。
6.6 hello的异常与信号处理
1.正常运行:
2.当我输入lf后程序结束,进程被回收
3.当我输入ctrl+z后,程序暂停
4.通过ps查看进程的pid
5.通过jobs查看进程的jid
6.通过kill杀死进程,但进程并没有被回收
7.通过ctrl+c回收进程
8.pstree指令(部分)
9.在执行过程中乱按键盘
说明进程对I/O设备具有阻塞作用。
6.7本章小结
本章主要讲述了异常,异常控制流发生在计算机系统的各个层次。比如,在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序,一个程序可以通过回避通常的栈规则,并执行到其他函数中的任意位置的非本地跳转来对错误做出反应。hello程序的运行,通过键入命令,shell处理分析指令,通过fork产生一个子进程,再通过execve加载进程,最后再通过父进程回收进程,实现hello的运行。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址(LogicalAddress)是指由程序产生的与段相关的偏移地址部分。就是hello.o里面的相对偏移地址。
线性地址:地址空间(address space) 是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间(linear address space) 。就是hello里面的虚拟内存地址。
虚拟地址:CPU 通过生成一个虚拟地址(Virtual Address, VA) 。就是hello里面的虚拟内存地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。计算机系统的主存被组织成一个由M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。就是hello在运行时虚拟内存地址对应的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
段描述符(segment descriptor)”,段描述符具体地址描述了一个段。这样,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,由8个字节组成,如下图
图示比较复杂,可以利用一个数据结构来定义它,不过,在此只关心一样,就是Base字段,它描述了一个段的开始位置的线性地址。 Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。用GDT还是LDT这是由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT。 GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。 如图:
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。 还是挺简单的,对于软件来讲,原则上就需要把硬件转换所需的信息准备好,就可以让硬件来完成这个转换了。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU的页式内存管理单元,负责把一个线性地址,转换为物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。 另一类“页”,我们称之为物理页,或者是页框(frame)、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。 这里注意到,这个total_page数组有2^20个成员,每个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。
分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。 每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中,将别个的保存下来。 每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位) 依据以下步骤进行转换:
(1)从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器;
(2)根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了;
(3)根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
(4)将页的起始地址与线性地址中最后12位相加,得到最终我们想要的。
7.4 TLB与四级页表支持下的VA到PA的变换
下图给出了Core i7 MMU 如何使用四级的页表来将虚拟地址翻译成物理地址。36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1 提供到一个Ll PET 的偏移量,这个PTE 包含L2 页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
先将虚拟地址转换为物理地址,对物理地址进行分析,物理地址由CT、CI、CO组成,用CI进行索引,如果匹配成功且valid值为1,则称为命中,根据偏移量在L1cache中取数,如果不命中,在分别到L2、L3和主存中重复上述过程,具体过程如下图所示:
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的PID。为了给这个新进程创新虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为制度,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork在新进程中被返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
虚拟内存和内存映射在将程序加载到内存的过程中扮演着关键的角色。假设运行时在当前进程中的程序执行了如下的execve调用:execve(“hello.out”,NULL,NULL);用hello.out程序有效代替了当前程序。加载并运行hello.out需要以下几个步骤:
1.删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
(在虚拟内存中的习惯说法中,DRAM缓存不命中称为缺页。下图为缺页之前我们的示例页表的状态。CPU引用了VP3的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3并未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,改程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4.如果VP4已经被修改了,那么内核就会将她复制回磁盘中。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不在缓存在主存中这一事实。接下来,内核从磁盘复制VP3带内存中的PP3,更新PTE3,随后返回。当一场程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重新发送到地址翻译硬件,但是现在,VP3已经缓存在主存中了,那么缺页命中也能由地址翻译硬件正常处理了,下图展示了缺页之后我们的实例页表的状态。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片。要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用,空闲快可用来分配。空闲块保持空闲,知道它显式地被应用程序所分配。一个已分配的块保持已分配状态,知道它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
策略:1.隐式空闲链表:在每个块的结尾处添加一个脚部(边界标记),其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是距当前块开始位置一个字的距离。考虑当分配器释放当前块时所有可能存在的问题
(1)前面的块和后面的块都是已分配的:两个邻接的块都是已分配的,因此不能进行合并。所以当前块的状态只是简单地从已分配变成空闲。
(2)前面的块是已分配的,后面的块是空闲的:当前块与后面的块的合并。用当前块和后面快的大小的和来更新当前块的头部和后面快的脚部。
(3)前面的块是空闲的,后面的块是已分配的:前面的块和当前块合并。用两个块大小的和来更新前面快的头部和后面快的脚部。
(4)前面和后面快都是空闲的:要合并所有的三个块形成一个单独的空闲块,用三个块大小的和来更新前面块的头部和后面块的脚部。在每种情况中,合并都是在时间常数内完成的。
2.显式空闲链表:使用双向链表而不是隐式的空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。一种方法是后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查出最近使用过得块。释放一个块可以在常数时间内完成。另一种方法是按照地址顺序来维护链表,其中链表中的每个快的地址都小于它后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的先驱。平衡点在于按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章主要讲述了虚拟内存的相关知识,并在此基础上要理解malloc如何管理虚拟内存,地址的存储空间,intel地址的段式管理,页式管理,TLB与四级页表支持下的VA到PA的变换,三级Cache支持下的物理内存访问,hello进程fork和execve时的内存映像,动态分配内存管理等等,这些就是hello程序涉及到的内存管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口一个Linux文件就是一个m个字节的序列:B0,B1,B2……Bm-1,所有的I/O设备都被模型化为文件,而所有的输入和输出都被当做对响应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单的低级的应用接口,被称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix IO接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。 2.Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为K 。
4.读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
函数:
1.进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件的:int open(char *filename, int flags, mode_t mode);open 函数将filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
2.进程通过调用close 函数关闭一个打开的文件。int close(int fd);
3.应用程序是通过分别调用read函数来执行输入。ssize_t read(int fd, void *buf, size_t n);read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
4.应用程序是通过分别调用write函数来执行输出。ssize_t write(int fd, const void *buf, size_t n);write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。返回:若成功则为写的字节数,若出错则为-1。
8.3 printf的实现分析
先查看一下print的主体函数代码:
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获得了函数的第一个参数。理解第二句,首先让我们查看一下vsprint的代码
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);
}
这个函数返回的是字符串的长度,作用是将输出格式化。按照格式fmt将传入的参数格式化输出。接下来就是write函数
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
Write函数实现的是将栈中的参数放入寄存器中,最后一句是调用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
通过分析可以发现这个函数的功能就是实现显示格式化的字符串。主要涉及硬件方面的操作:
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)
8.4 getchar的实现分析
异步异常-键盘中断的处理:进程接受来自键盘中断的异常,运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码键盘接口会得到一个代表该按键的键盘扫描码,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5 本章小结
本章主要讲述了I/O设备的相关知识。Linux提供了少量基于Unix I/O模型的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行I/O重定向。hello进程通过I/O设备的正确信号来执行。通过本章也了解到了print函数、getchar函数的具体实现。
结论
hello艰辛的一生:
- 通过I/O等键入设备编辑hello源代码,生成hello.c文件
- hello.c经过cpp预处理,加入头文件,经过宏替换等等生成hello.i文件
- hello.i经过编译器编译生成hello.s汇编文件
- hello.s经过汇编器生成hello.o可重定位目标文件
- hello.o经过链接生成可执行文件。
- 运行主要通过shell处理命令行命令建立子进程,通过execve加载子进程。
- CPU通过逻辑控制流制和虚拟内存制造假象,好像hello在独占整个内存
- 对内存的访问,MMU将虚拟内存的地址映射为物理地址,再通过3级cache 查找,未命中后再到主存查找,找到后返回给进程。
- 各种信号可以让shell进行不同的操作
- 结束运行后,shell通过父进程回收子进程,并且将占用的相关资源删除,hello结束了他的一生,好像没有来过。
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.c | 源代码 |
---|---|
hello.i | 预处理 |
hello.s | 编译后的代码 |
hello.o | 汇编后的代码 |
hello.out | 链接后的代码 |
hello | 可执行程序 |
hello.oobjdump | hello.o的反汇编代码 |
hello.objdump | 存放hello的反汇编代码 |
参考文献
[1] 我理解的逻辑地址、线性地址、物理地址和虚拟地址
https://blog.youkuaiyun.com/haiross/article/details/50995750
[2] [转]print函数深入解析https://www.cnblogs.com/pianist/p/3315801.html
[3] ELF 构造:https://www.cs.stevens.edu/~jschauma/631/elf.html
[4] 《深入理解计算机系统》(原书第三版)
[5] ]动态链接的整个过程
https://blog.youkuaiyun.com/a1342772/article/details/77688148