计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L021818
班 级 2003006
学 生 秦梓涵
指 导 教 师 吴锐
计算机科学与技术学院
2021年5月
摘 要
本文从一个简单的hello.c文件入手,依次按照预处理、编译、汇编、链接、进程管理、存储管理和IO管理的顺序对一个程序的一生进行分析,从编写的文本文件,一直到到在硬件系统上的执行。
关键词:预处理;编译;汇编;链接
目录
第1章 概述
1.1 Hello简介
P2P过程:P2P即From Program to Process。Hello程序在一开始只是我们编写的一个文本文件,系统调用编译器的预处理器将文本文件中的注释删除,将文件内容包含进源程序并执行宏替换,生成.i文件。之后系统调用编译器将c语言转化成汇编语言指令,生成.s文件。再调用汇编器将汇编指令转换为机器指令,生成.o文件。最后使用链接器将若干文件链接,得到一个可执行文件,这就是我们说的Program。当我们在shell里运行这个程序时,系统将这个程序加载至内存,然后为这个程序fork一个新的进程,称为Progress,在子进程中,使用execve执行这个程序。这就实现了Program to Progress。
020过程:020即From Zero-0 to Zero-0。程序从一片空白开始,经过P2P的过程,我们得到了一个ELF格式的可执行文件。shell在子进程中使用execve执行这个程序时,系统将会把这个程序加载到内存,等待进程结束时,它的父进程将会回收hello,而系统内核将会对内存进行清理,最后什么也没有留下,即020。
1.2 环境与工具
硬件环境:AMD Ryzen 7 5800H; RAM 32G; NVIDIA GeForce RTX3050 Ti Laptop GPU。
软件环境:Windows 10; VirtualBox; Ubuntu-20.04.4。
开发与调试工具:gcc; vim; edb; readelf; HexEdit
1.3 中间结果
文件名 | 文件作用 |
hello.c | hello程序的源代码 |
hello.i | 源代码经过预处理后的结果 |
hello.s | 编译后生成的汇编程序 |
hello.o | 汇编后生成的可重定位文件 |
hello | 链接之后生成的可执行文件 |
a.out | 添加相关编译参数的可执行文件 |
1.4 本章小结
本章简要介绍了P2P、020的含义,分析了程序产生到执行过程中的几个主要过程,以及需要的环境、产生的中间文件等,后面的章节将会围绕这几个部分展开进一步的分析。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理器将会展开以#起始的行,并试图解释为预处理指令(包括条件编译指令#if/#ifdef/#ifndef/#else/#elif/#endif;宏定义#define;源文件包含指令#include;行控制指令#line;错误指令#error以及指定编译器功能指令#pragma)。执行完所有的预处理指令后,预处理器生成一个.i文件。
2.2在Ubuntu下预处理的命令
在bash里输入gcc -E hello.c -o hello.i :
产生hello.i文件:
2.3 Hello的预处理结果解析
原hello.c文件如下图:
对比两个文件的内容我们可以看到,预处理器完成了如下过程:首先,预处理器将stdio.h、unistd.h和stdlib.h中的内容读入到了hello.i中,其中包括一些类型的声明定义和一些函数的声明。然后预处理器将所有用#define定义的宏替换为了对应的值并且删除了注释内容。
2.4 本章小结
本章介绍了预处理过程相关的内容,从预处理的概念与内容入手,通过比较hello.c和hello.i中内容的不同之处,对预处理的过程有了更加深入的认识。
第3章 编译
3.1 编译的概念与作用
编译的概念:编译器将目标源程序hello.i翻译成汇编语言程序hello.s。
编译的作用:为不同的源程序语言提供了统一的汇编语言输出,便于机器以相同的标准执行。
3.2 在Ubuntu下编译的命令
在bash里输入gcc -S hello.i -o hello.s:
产生hello.s文件:
3.3 Hello的编译结果解析
3.3.1 数据
该程序中包含两个字符串常量:
分别对应着源程序中printf函数的两个格式化控制串,即"用法: Hello 学号 姓名 秒数!\n"和"Hello %s %s\n"。
main的参数变量argc和argv:
参数argc和argv分别为int型和char*[]型,在参数传递时分别保存在了%edi和%rsi寄存器中,之后作为局部变量,被程序保存在了栈中。
循环变量i:
循环变量i同样以局部变量的形式存在,保存在程序栈中,在每次循环结束时,对循环变量i进行更新。
3.3.2 赋值
程序中出现了对局部变量i的赋值:
使用movl将4字节的立即数0赋值到-4(%rbp)处,即在程序栈中储存临时变量的位置。
3.3.3 算数操作
汇编程序中显式地使用add指令来实现了i++操作:
3.3.4 关系操作
第一处操作是在判断argc是否为4时,即:
对应的汇编语言实现为:
cmpl对应着局部变量argc是一个int型的数据。
第二处操作是在循环时判断i的大小,即:
在汇编语言实现为:
因为i是整型,所以这里把i<8的操作转换为了i<=7。
3.3.5 数组/指针/结构操作
在程序中有3次数组的访问如下:
首先,程序计算-32(%rbp)即argv的地址,保存在%rax中。argv为char*[]型,%rax + 16相当于计算了argv[2],将argv[2]的值保存在%rdx中,这里argv[2]是一个指针,所以需要8个字节的寄存器保存。同理,程序计算argv[1],并将其保存在%rsi中。
在执行完printf后,程序计算argv[3],将其保存至%rdi中。
3.3.6 控制转移操作
在源程序中,程序有循环和分支语句:
在汇编指令中,使用跳转指令来完成这一功能:
对于分支语句,程序首先比较-20(%rbp)处的变量(即argc)和4的大小关系,如果不相等,则直接执行下面的语句,如果相等,则跳转到.L2标签。
对于循环语句,本程序采用了先赋初值,然后jump to middle的策略,程序声明了一个局部变量i,赋初值为0,然后跳转到.L3位置,比较i的值与7的大小关系,如果i<=7,则跳转到.L4位置处,否则继续向下执行。
3.3.7 函数操作
printf函数:
程序第一次调用printf函数输出了一个字符串,在汇编程序中实现如下:
其中,.LC0为:
这里printf输出一个以\n结尾的字符串,因而编译器直接将其优化成了puts函数,这里常量字符串的寻址使用了通过程序计数器(PC)的相对偏移实现。
程序第二次调用printf时输出了该程序的两个参数,汇编程序实现如下:
这里程序将argv[1]和argv[2]分别保存在%rsi和%rdx中,然后将格式化字符串的地址保存在%rdi中,最后调用printf函数输出内容。
其中,.LC1为:
exit函数:
程序将立即数1保存在%edi寄存器中,然后把这个参数传递给exit函数。
atoi函数:
程序将argv[3]的值取出保存到寄存器%rdi中,然后调用atoi函数将值传入。
sleep函数:
程序将atoi的返回值保存到%edi寄存器中,然后调用sleep函数将参数传入。
getchar函数:
直接调用getchar函数。
3.4 本章小结
本章讨论了编译相关的内容,编译器将一个经过预处理的文件hello.i通过编译器转换为hello.s文件的过程,对汇编程序的具体执行过程做的深入的分析,尤其对数据、变量赋值、类型转换、算术操作、关系操作、控制转移、指针数组操作和函数操作和C语言中的语句做了详细对比,学习了编译器如何将抽象的高级语言转化成低级的汇编语言。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:将汇编语言程序翻译成机器语言并保存在.o文件中的过程。
汇编的作用:将汇编语言的指令转化成机器指令。
4.2 在Ubuntu下汇编的命令
在bash下输入:gcc hello.s -c -o hello.o。
生成hello.o文件,使用readelf查看:
4.3 可重定位目标elf格式
ELF头:
ELF头的开始是一个16字节的魔数,其中前4个字节7f 45 4c 46描述了该文件是否满足ELF格式,第5个字节02描述了该系统架构为x86-64,第6个字节01表示使用小端法,第7个字节01表示ELF的主版本号。下面是有关该ELF文件的一些其他信息。
节头部表:
程序的节头部表描述了每一个节的名称、大小、类型、地址、偏移量、旗标、链接、信息、对齐等信息。
在节头部表中注明了本文件中没有节组、程序头和动态节。
重定位节:
其中在类型部分,以PC32结尾的是静态链接的内容,而以PLT32结尾的是动态链接内容。在最后有一个与重定位相关的异常处理单元.rela.eh_frame。
符号表:
符号表中存放了所有引用的函数和全局变量的信息。
4.4 Hello.o的结果解析
在Bash中输入objdump -d -r hello.o 得到hello.o的反汇编程序:
将其与hello.s文件对比可以发现,反汇编程序主要发生了三种变化:
条件分支变化:
跳转指令由标签转变成了具体的相对偏移地址,我们可以知道标签在经过汇编过程时被删除,在实际机器指令中并不存在。
函数调用变化:
在.s程序中,call的后面紧跟着函数名,而在反汇编程序中,call的后面紧跟着下一条指令的地址。这是因为该程序调用的函数是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址。对于这一类函数调用,call指令中的相对偏移量暂时编码为全0,然后在.rela.text节添加重定位条目,等待链接时的进一步确定。
数据访问变化:
对于同一全局变量的访问,反汇编文件中需要通过链接时的重定位确定地址,而在.s文件中仍以.LC0占位符表示。
4.5 本章小结
本章介绍了汇编的过程,hello程序从汇编语言hello.s到机器指令hello.o,通过查看hello.o的ELF格式文件以及利用objdump进行反汇编查看汇编代码并与hello.s比较的方式,加深了对汇编过程的理解。
第5章 链接
5.1 链接的概念与作用
链接的概念:链接是将各种代码和数据片段收集并组合称为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。链接是由链接器的程序自动执行。
链接的作用:链接器使分离编译成为可能;一个大型应用程序不再需要组织巨大的源文件,而是可以分解为更小,更好管理的模块,可以独立修改和编译这些模块,当我们改变这些模块中的一个时,只需简单编译,重新链接使用,不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
在bash中输入: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产生hello文件:
5.3 可执行目标文件hello的格式
使用readelf打开hello文件如下:
节头部表:
各段的起始地址,大小等信息如图所示。
5.4 hello的虚拟地址空间
使用edb打开hello程序:
下面,开始查看hello的虚拟空间各个段的信息:
首先,我们用Memory Regions功能查看第一个部分:
由前16个字节可以看到,这一部分就是我们ELF文件的头。
下一个内存区域是可读可执行的,我们查看该区域可知:
这一部分是我们指令的装载地址。
第三个内存区域是只读数据域,我们查看这一部分:
这一部分编码了我们代码中字符串常量等数据。
下一部分是可读可写的:
这一部分是运行时堆,由malloc函数管理内存分配,同时作为全局变量的数组也会保存在这一部分。
最后一部分是用户栈:
5.5 链接的重定位过程分析
在bash中输入objdump -d -r hello,结果如下:
与hello.o反汇编的结果对比可知,hello程序反汇编结果有如下变化:
含有汇编代码的段增多:
hello.o反汇编的代码只有.text段有汇编代码:
hello反汇编的代码除了.text段有汇编代码外,还有.init段、.plt段、.fini段。
增加了一些函数:
其中包括main的入口函数_start等:
以及一些外部库函数:
进行重定位:
这里我们增加-no-pie参数重新进行编译:
在hello.o的反汇编文件中,调用函数时的偏移地址为0:
只留下了若干重定位条目等待重定位,重定位时,链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
在这个过程中,这些重定位条目告诉链接器32位PC相对地址或32位绝对地址进行重定位,这些重定位条目通过计算地址或直接调用保存的绝对地址,达到重定位的目的。
在重定位后,这些部分都被替换为了准确的地址:
5.6 hello的执行流程
使用edb打开hello程序,观察右侧的rip可知程序调用的子程序及其地址:
hello运行过程中,调用的子程序如下:
子程序名 | 程序地址 |
ld -2.31.so_dl_start | 7ff3394d8ea0 |
ld-2.31.so_dl_init | 7ff3394e7630 |
hello_start | 400500 |
libc-2.31.so__libc_start_main | 7ff339100ab0 |
Hello_printf@plt | 4004c0 |
Hello_sleep@plt | 4004f0 |
hello!getchar@plt | 4004d0 |
libc-2.31.so!exit | 7ff339122120 |
5.7 Hello的动态链接分析
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。因而,我们为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。
为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
在节头部表中我们可以看到,.got.plt的起始地址为0x404000。
在edb中我们找到该位置:
可以发现在dl_init未执行时,0x404008往后的16个字节均为0,在dl_init执行后如下图:
这里多了两个地址,分别为0x7ff339ccc190和0x7ff339af5cc0,这里保存的即动态链接后,函数的最终地址。
5.8 本章小结
本章通过解释链接的概念及作用、分析hello的ELF格式以及hello的虚拟地址空间、重定位过程、执行流程和动态链接过程,深入学习了可重定位文件到可执行文件的流程和链接的各个过程。同时,根据重定位算法,对链接的重定位过程进行了分析并使用edb作为工具,对hello的执行流程与动态链接过程进行了分析。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程是一个执行中的程序的实例。系统中每一个程序都运行在某个进程的上下文中。上下文是程序正确运行所需的状态的组成的。这个状态包括存放在内存中的程序的代码和数据,程序栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程的作用:首先,它提供一个独立的逻辑控制流,为程序员制造一种假象,好像我们运行的每个程序独占的使用处理器。其次,它提供一个私有的地址空间,它制造一种假象,好像我们的程序独立的使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
壳Shell-bash的作用:Shell执行一系列的读取解析步骤,然后终止。读取步骤读取来自用户的一个命令行。解析步骤将命令行解析,并运行程序。
处理流程:
1. shell打印一个命令行提示符,等待用户在输入命令行,然后对这个命令行求值。
2. 解析命令行之后,Shell调用函数检查第一个命令行参数是否为一个内置的Shell命令。如果是,它就立即执行这个命令。
3. 如果不是内置命令,那么shell建立一个子进程,并在子进程中执行所请求的程序。
6.3 Hello的fork进程创建过程
程序可以调用fork函数创建子进程。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。
对于我们的hello程序来说,首先我们在命令行中输入./hello 120L021818 秦梓涵 1:
shell读入一个字符串”./hello 120L021818”,然后将该命令行进行解析,得到argv[0]:”./hello”、argv[1]:”120L021818”、argv[2]:”秦梓涵”和argv[3]:”1”。
此后,shell先解析argv[0],发现./hello并不是一个内置的指令,因而shell将fork一个子进程用于执行hello程序。
6.4 Hello的execve过程
Shell将在子进程中调用execve函数执行hello程序。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC指向_start地址,_start最终调用hello中的main函数。
除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
6.5 Hello的进程执行
逻辑控制流:
一系列程序计数器PC的值的序列叫做逻辑控制流,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。各个进程将轮流使用处理器,在同一个处理器核心中,每个进程执行它的流的一部分后被暂时挂起,然后执行其他进程。
用户模式与内核模式:
处理器通过某个控制寄存器中的一个模式位来提供限制一个应用可以执行的指令以及它可以访问的地址空间范围的功能。该寄存器描述了当前进程运行的权限。当设置了模式位时,进程就运行在内核模式中。没有设置模式位时,进程就运行在用户模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存;而用户模式的进程不允许和执行特权指令、也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。
上下文切换:
内核为每个进程维持一个上下文。上下文就是内核重新启动的一个进程所需的状态。这个状态包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
进程时间片:
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
6.6 hello的异常与信号处理
hello执行过程中出现的异常种类可能会有:中断、陷阱、故障、终止。
中断:中断是来自处理器外部的I/O设备的信号的结果。硬件中断的异常处理程序被称为中断处理程序。
陷阱:陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
故障:故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则处理程序返回到内核中的abort(),abort()会终止引起故障的应用程序。
终止:终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。处理程序将控制返回给abort(),abort会终止这个应用程序。
在hello程序中,使用键盘中断程序触发的是中断机制,而shell在显示屏上输出的过程触发的是陷阱机制。
hello正常执行的状态:
在hello运行时按下Ctrl^z:
进程将发送SIGTSTP信号,将该进程暂时挂起,运行ps可以看到该进程仍然存在:
运行jobs可以看到该进程暂时终止:
运行fg可将该任务转为前台执行:
输入kill指令,将SIGKILL发送到对应进程,直接杀死该进程:
输入pstree,将所有进程以树的形式显示:
在运行时输入Ctrl^c,进程将发送SIGINT信号,结束进程:
6.7本章小结
本章介绍了进程的概念与作用,壳shell处理进程的过程以及执行hello程序时如何调用fork函数创建进程和调用execve执行hello程序。同时也分析了hello执行过程中可能引发的异常和信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址是指由程序产生的与段相关的偏移地址部分。就是hello.o里面的相对偏移地址。
线性地址:逻辑地址经过段机制后转化为线性地址,其地址空间是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间。即hello里面的虚拟内存地址。
虚拟地址:虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。仍为hello里面的虚拟内存地址。
物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。就是hello在运行时虚拟内存地址对应的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理:把一个程序分成若干个段进行存储,每个段都是一个逻辑实体,程序员需要知道并使用它。它的产生是与程序的模块化直接有关的。段式管理是通过段表进行的,它包括段号或段名、段起点、装入位、段的长度等。此外还需要主存占用区域表、主存可用区域表。
这个变换将逻辑地址映射到线性地址:逻辑地址 = 段标识符+段内偏移量。
段内偏移量:是一个不变的常量,直接送到最后的加法器,用于计算线性地址。
段标识符:由索引号、表指示器和请求者特权级组成。
索引号是段描述符表的索引,段描述表可细分为全局段描述符表和局部段描述表,这两者的寻找与切换由表指示器确定。
由逻辑地址到线性地址变换时,我们将逻辑地址进行分割成:段选择符+Offset。 看段选择符的TI,如果是0,那么切换到GDT,如果是1,那么切换到LDT。利用段选择符里面的索引号在相应的表里面进行取出段描述符 对段描述符里面的Base进行取出线性地址即Base+Offset。
7.3 Hello的线性地址到物理地址的变换-页式管理
由于我们使用了分页技术,因而需要我们将hello的线性地址转换为物理地址。首先,我们给出页的定义:
页,即N个连续字节的数组,具体可分为虚拟页和物理页,如下图:
如果不考虑多级页表等更加精细的机制,我们的地址映射将是这样进行的:线性地址,即虚拟地址,被分成了两部分:
线性地址=VPN(虚拟页号)+VPO(虚拟页偏移量)。
虚拟页号是一个索引,在当前进程的CR3寄存器指向当前的页表里面寻找虚拟页,并把里面存的物理页号PPN与物理页偏移量PPO返回,即:
物理地址=PPN(物理页号)+PPO(物理页偏移量)。
其中,页命中指虚拟内存中的一个字存在于物理内存中,缺页指引用虚拟内存中的字,不在物理内存中。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB:
TLB支持下的VA到PA的变换能够利用局部性原理,加快地址翻译速度。
TLB即翻译后备缓冲器,是一个具有如下性质的缓存:
1. 分页内存管理单元中一个小的具有高相联度的集合。
2. 实现虚拟页号到物理页号的映射。
3. 页数很小的页表可以完全存放在TLB中。
如果缓存在TLB中的话,那么TLB可以直接将VPN映射到PTE,以上步骤都是在CPU内部的MMU单元完成的,因而速度较快。
四级页表:
四级页表支持下的VA到PA的变换能够利用多级页表,降低内存占用。
如果我们的页面大小4KB,48位地址空间,8字节的PTE,那么我们的页表占用的空间至少应该是512GB,这显然不现实。我们发现其实有很多页我们并没有用上,于是可以不放入PTE里面,而采用多级页表的处理思路,如下图:
由CR3寄存器指向L1的PT,然后由VPN1作为索引,进行寻找。找到PTE以后,以PTE条目里面的Base作为基址,再以VPN2作为索引,重复上述操作,直到找到L4的PT里面的PTE。以这个作为PPN,并上PPO,作为虚拟地址。
7.5 三级Cache支持下的物理内存访问
缓存的出现是为了缓解存储设备和CPU之间巨大的速度差异。处理器对内存数据的访问,一般是通过cache进行。具体过程为:
通过地址解析出缓存的索引和偏移,对缓存进行访问,匹配标记查找是否含有相关的字,如果命中,则将数据发送给CPU,如果没有命中,则访问下一级缓存,取出这个字,存入高一级缓存,返回数据给CPU。
在物理内存访问的过程中,这个过程被执行为:
对于给定的物理地址PA,将PA分割成CT CI CO。其中CI是组号,定位了应该出现的组,CO是offset定位了偏移量,CT是tag即标识的tag。
首先对本层的Cache进行寻找(从L1开始)。硬件会定位到CI的组索引,然后对组里面的每一行进行比较tag。如果tag相同且有效,那么找到了。
如果找到,就直接返回数据。如果找不到,就会到L2里面进行寻找,重复上述操作直到找到为止。
7.6 hello进程fork时的内存映射
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve 函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。.bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页:引用虚拟内存中的字,不在物理内存中,DRAM 缓存不命中。
缺页时的执行流程如下:
首先,处理器生成一个虚拟地址,并将其传送给MMU。MMU生成PTE地址(PTEA),并从高速缓存/主存请求得到PTE。高速缓存/主存向MMU返回PTE,PTE的有效位为零, 因此 MMU 触发缺页异常。
缺页处理程序确定物理内存中的牺牲页 (若页面被修改,则换出到磁盘),缺页处理程序调入新的页面,并更新内存中的PTE。缺页处理程序返回到原来进程,再次执行导致缺页的指令。
7.9动态存储分配管理
动态内存管理的基本方法与策略:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组大小不同的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是以分配的,要么是空闲的。已分配的块显式的保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,两种风格都要求应用显式的分配快。它们的不同之处在于由哪个实体来负责释放已分配的快。
显式分配器:要求应用显式地释放任何已分配的块。例如C标准库提供一种叫malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。
隐式分配器:另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫垃圾收集器,而自动释放未使用的已分配块的过程叫做垃圾收集。
7.10本章小结
本章介绍了hello程序的存储管理,包括各类地址空间的转换,段式管理和页式管理等,介绍了VA到PA的转换,物理内存访问。以及调用fork函数和execve函数时的内存映射,发生缺页故障后的处理方法和动态储存的分配管理方法。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
文件是Linux管理的基本思想,所有的IO设备都被抽象为文件,所有的输入输出操作都作为对文件的操作。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得输入和输出都能以一种统一且一致的方式的来执行。
8.2 简述Unix IO接口及其函数
Unix I/O接口:
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.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数:
1. int open(char* filename,int flags,mode_t mode),进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。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 write(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 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;
}
在形参列表中的...表示传递参数个数不确定,va_list的定义为 typedef char *va_list是一个字符型指针,(char*)(&fmt) + 4) 表示的是...中的第一个参数。
此后,程序调用vsprintf函数,vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出,并返回字符串长度。
之后,程序调用write函数,将缓冲区中的前i个字符输出到屏幕上,我们对write进行反汇编:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
Write将参数传如寄存器,然后进入一个陷阱-系统调用。sys_call将串里面的字节,从寄存器里面通过总线,复制到显卡显存里面,存放ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。最后我们的打印字符串就显示在了屏幕上。
8.4 getchar的实现分析
首先看getchar()函数的函数体:
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
bb=buf;
}
return(–n>=0)?(unsigned char)*bb++:EOF;
}
当用户键入回车之后,getchar从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符显示到屏幕。如果用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux的I/O设备的基本概念和管理方法,以及Unix I/O接口及其相关函数,并且分析了printf函数和getchar函数的工作过程。
结论
hello程序诞生于程序员一下一下地敲击键盘,将c语言的源码输入到一个文本文件中,以ASCII码的形式保存。紧接着,预处理器将包含的头文件添加到源码中,将宏定义进行字符串替换以及对其他预处理指令进行处理,这时hello.c文件被补全为了hello.i。编译器将补全后的hello.i编译为汇编语言组成的hello.s文件。然后汇编器将hello.s文件中的汇编指令转化为机器指令,得到可重定位的hello.o文件。链接器进行符号解析和重定位,将对hello.o文件和其他被调用的库文件进行链接,得到可执行的hello程序。我们在bash里输入./hello执行这一程序,bash进程将fork一个hello子进程,子进程调用execve函数对虚拟内存进行映射,执行hello程序。hello程序中的main函数结束后,子进程将终止,由父进程进行回收。
通过对hello程序的一生的分析,我了解到原来简单的源代码背后是计算机系统庞大而又复杂的抽象,这门课的学习让我体会到计算机底层执行时的复杂结构,增加了对计算机系统的整体认识。
附件
文件名 | 文件作用 |
hello.c | hello程序的源代码 |
hello.i | 源代码经过预处理后的结果 |
hello.s | 编译后生成的汇编程序 |
hello.o | 汇编后生成的可重定位文件 |
hello | 链接之后生成的可执行文件 |
a.out | 添加相关编译参数的可执行文件 |
参考文献
[1] Bryant.R.E.. 深入理解计算机系统[M]. 机械工业出版社,2016