本文主要是介绍哈工大计算机系统大作业 程序人生 Hello‘s P2P,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
摘 要
本文通过分析hello程序从简单的编写完成的c语言代码开始,通过预处理、汇编、编译、链接等一系列操作生成可执行文件,再创建进程,成为可执行程序直至程序执行结束,释放资源的全过程,展示了Linux操作系统下hello程序的生命周期,简单说明了linux系统进行进程管理,存储管理以及I/O管理等的实现机制和底层原理。
关键词:Hello程序;操作系统;预处理;编译;汇编;链接;进程;内存管理;IO管理
目录
第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 本章小结............................................................................... - 7 -
第3章 编译................................................................................... - 8 -
3.1 编译的概念与作用............................................................... - 8 -
3.2 在Ubuntu下编译的命令.................................................... - 8 -
3.3 Hello的编译结果解析........................................................ - 8 -
3.4 本章小结............................................................................. - 10 -
第4章 汇编.................................................................................. - 11 -
4.1 汇编的概念与作用............................................................. - 11 -
4.2 在Ubuntu下汇编的命令.................................................. - 11 -
4.3 可重定位目标elf格式...................................................... - 11 -
4.4 Hello.o的结果解析........................................................... - 13 -
4.5 本章小结............................................................................. - 16 -
第5章 链接................................................................................. - 17 -
5.1 链接的概念与作用............................................................. - 17 -
5.2 在Ubuntu下链接的命令.................................................. - 17 -
5.3 可执行目标文件hello的格式......................................... - 17 -
5.4 hello的虚拟地址空间....................................................... - 19 -
5.5 链接的重定位过程分析..................................................... - 20 -
5.6 hello的执行流程............................................................... - 22 -
5.7 Hello的动态链接分析...................................................... - 22 -
5.8 本章小结............................................................................. - 23 -
第6章 hello进程管理.......................................................... - 24 -
6.1 进程的概念与作用............................................................. - 24 -
6.2 简述壳Shell-bash的作用与处理流程........................... - 24 -
6.3 Hello的fork进程创建过程............................................ - 24 -
6.4 Hello的execve过程........................................................ - 25 -
6.5 Hello的进程执行.............................................................. - 25 -
6.6 hello的异常与信号处理................................................... - 26 -
6.7本章小结.............................................................................. - 29 -
第7章 hello的存储管理...................................................... - 30 -
7.1 hello的存储器地址空间................................................... - 30 -
7.2 Intel逻辑地址到线性地址的变换-段式管理.................. - 30 -
7.3 Hello的线性地址到物理地址的变换-页式管理............. - 30 -
7.4 TLB与四级页表支持下的VA到PA的变换................... - 30 -
7.5 三级Cache支持下的物理内存访问................................ - 31 -
7.6 hello进程fork时的内存映射......................................... - 32 -
7.7 hello进程execve时的内存映射..................................... - 33 -
7.8 缺页故障与缺页中断处理................................................. - 33 -
7.9动态存储分配管理.............................................................. - 34 -
7.10本章小结............................................................................ - 35 -
第8章 hello的IO管理....................................................... - 36 -
8.1 Linux的IO设备管理方法................................................. - 36 -
8.2 简述Unix IO接口及其函数.............................................. - 36 -
8.3 printf的实现分析.............................................................. - 37 -
8.4 getchar的实现分析.......................................................... - 39 -
8.5本章小结.............................................................................. - 39 -
结论............................................................................................... - 40 -
附件............................................................................................... - 41 -
参考文献....................................................................................... - 42 -
第1章 概述
1.1 Hello简介
1.P2P:程序员编写出hello的原始C语言代码,保存为hello.c,作为一个program,然后经过预处理、编译、汇编、链接得到可执行文件,在shell中,进程管理为hello创建进程并加载可执行文件,得到运行中的hello进程,这就是hello从代码变成进程(from program to process)的过程。
2.020:shell为hello创建进程并加载hello的可执行文件,映射虚拟内存,进入程序入口,程序载入物理内存,main函数执行目标代码,CPU为运行中的hello分配时间片执行逻辑控制流。最终,hello正常退出或收到信号后终止,都会使得操作系统结束hello进程,释放其占用的一切资源,返回shell,这就是hello的从无到有再到无的过程(from zero to zero)。
1.2 环境与工具
硬件环境:AMD Ryzen 5 4600H with Radeon Graphics,RAM 16GB;
软件环境:Windows 11;Ubuntu 20.04.4 LTS;
工具:编译环境gcc,文本编辑器gedit,反汇编工具edb ,objdump等。
1.3 中间结果
hello.c | C语言源程序 |
hello.i | hello.c预处理的文本文件 |
hello.s | hello.i编译后的汇编文件 |
hello.o | hello.s汇编后的可重定位文件 |
hello_elf.txt | hello.o经过readelf分析得到的文本文件 |
hello_o.txt | hello.o经过objdump反汇编后得到的文本文件 |
hello | 链接后的可执行文件 |
hello_exe_elf.txt | hello经readelf分析得到的文本文件 |
hello_out.txt | hello经过objdump反汇编后得到的文本文件 |
1.4 本章小结
本章简单介绍了hello.c程序P2P,020的过程,列出了本次实验环境的相关信息并列举了本次实验所生成的中间结果文件。
第2章 预处理
2.1 预处理的概念与作用
1.预处理的概念:
程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
2.预处理的作用:
预处理可以处理条件编译指令,头文件指令,特殊符号等。对于c/c++的预处理操作,预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive),其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)。
2.2在Ubuntu下预处理的命令
Ubuntu下的预处理命令为:gcc hello.c -E -o hello.i
2.3 Hello的预处理结果解析
hello.c文件经过预处理得到hello.i文件,打开hello.i文件进行分析:
首先可以看到文件行数增加许多,并且原程序的代码在文件的最后面:
预处理操作对原文件中的宏进行了宏展开,接下来的大部分代码都是被include的头文件的源码内容:
即stdio.h, unistd.h, stdlib.h等头文件被依次展开,代码被加入,并且若是再展开这些头文件的过程中遇到了新的以#开头的define,预处理器会继续进行展开。
2.4 本章小结
本章简单介绍了预处理的概念和功能,并且在linux系统下对hello.c文件进行了预处理操作。分析得到的hello.i文件可以看出预处理操作对原文件中被include包含的头文件进行了扩充展开,并且进行了一些其他处理,比如将定义的宏进行符号替换,根据指令进行选择性编译等。
第3章 编译
3.1 编译的概念与作用
1.编译的概念:
广义的编译是说将某一种程序设计语言写的程序翻译成等价的另一种语言。
这里对hello.i的编译则是指利用编译程序以预处理文本文件(hello.i)为输入,以汇编程序(hello.s)为输出的过程。
2.编译的作用:
编译的主要作用就是将输入的高级程序设计语言源程序翻译成以汇编语言或
机器语言表示的目标程序。
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
1.数据:
(1)字符串:在汇编程序中,首先就能看到程序有两个常量字符串,都存储在只读数据段中
(2)局部变量:进入main函数主体,函数声明了一个int类型的局部变量i,从汇编码中可以看出局部变量i被存放在栈中以%rsp-4的值为地址的地方,如图:
(3)传入main函数的参数:main函数接收符号数argc和字符型数组指针argv作为参数,根据寄存器使用规则,这两个参数分别通过%edi和%esi传递。由汇编码可以看出,argc存放于%rsp-20处,argv[]是元素皆为字符指针的数组,存放在%rsp-32处,具体存放的汇编码如下图所示:
由此图同样可以看出,在存放这些参数和局部变量之前,栈指针首先下移了32位。
2.赋值操作:
在汇编码中,赋值操作通常使用mov命令来实现,在原代码中,存在局部变量赋值:i=0,如下图所示:
3.算术操作:
原代码中的算术操作主要是对局部变量i进行加法操作:i++,加法操作一般使用add相关命令来实现,如下图所示:
4.关系操作与控制转移:
关系操作一般使用命令cmp来实现,一般后续还会跟随条件判断得到结果之后伴随的控制转移(跳转语句)。原代码中涉及到关系相关的操作包括:
- argc!=4:作为条件判断的关系操作,比较argc和4,相等则会跳转到L2。命令如下:
2. i<8:作为原程序中循环的判定条件,判定条件后如果i<=7会跳转到L4。命令如下:
5. 数组/指针操作:
原代码中的数组为传递入main函数的字符串数组argv[],在printf和后面的sleep函数中都涉及到取出argv[]中的数组元素的操作,具体如下:
上图为printf取argv[]元素的过程,可以看出前三行先将数组的首地址赋给rax,之后rax的值+16(每个元素是指针类型,长度为8个字节),即取出argv[2],之后这里的值被赋给rdx,同样的操作在后三行argv[1]的值被赋给rax。之后把他们作为参数,调用函数print。之后sleep函数的数组操作类似于此,不再赘述。
6. 函数操作:
汇编码中的函数操作一般使用call命令来使用,原代码中涉及到不少函数操作,部分列举如下(因为形式都基本一样,只是函数名不同):
这些函数的返回值都保存在rax寄存器中。
3.4 本章小结
本章主要介绍了编译概念和作用,以及在linux系统下是用什么命令来进行编译,较为详细的分析了hello代码进行编译后的汇编程序与原代码各部分的对应,以及分析了各种运算和操作是如何通过汇编码来实现的。
第4章 汇编
4.1 汇编的概念与作用
1. 汇编的概念:
汇编是指从 .s 到 .o 即从编译后的文件到生成机器语言二进制程序的过程。在这个过程中,汇编器将汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件(一个包含程序指令编码的二进制文件)中。
2. 汇编的作用:
汇编的主要作用是将汇编代码转换为能够直接被CPU执行的机器码的形式,使其在链接后能被机器识别并执行。
4.2 在Ubuntu下汇编的命令
汇编命令:gcc hello.s -c -o hello.o
4.3 可重定位目标elf格式
1. 执行readelf命令:
在linux系统下使用命令:readelf -a hello.o > hello_elf.txt,将elf格式的内容重定向至文件hello_o.txt中查看:
2. ELF头:
ELF头(ELF header)以16字节的序列Magic开始,这个序列中的7f,45,4c,46分别对应ASCII码的Del,E,L,F,其余位标识位数,小/大端序,版本号等。在Magic序列之后还标注了部分该重定位目标文件的其他信息。
3.节头:
节头部表包含了文件中出现的各个节的语义,每个节都从0开始,用于重定位。
在文件头中得到节头表的信息后,可以使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及所占空间的大小。
同时可以观察到,.text段(代码)是可执行的,但是不能写;.data段和.rodata段(已初始化或为0的全局变量)都不可执行且.rodata段(只读代码段)不可写;.bss段(未初始化的全局变量)大小为0。
4. 重定位节:
重定位节记录了各段引用的符号的相关信息。在链接时,可以通过重定位节对这些位置的地址进行重定位。即在重定位的过程中,链接器结合重定位节中的偏移量和其他信息来计算正确的地址。
从下图可以看出,hello程序需要重定位的符号有:.rodata, puts, exit, .printf, atoi, sleep, getchar等。
5.符号表:
符号表用来存放在程序中定义和引用的函数和全局变量的信息。具体如下图所示:
从图中来看,Value字段是符号相对于目标节的起始位置偏移;Size字段代表目标的大小;Type字段说明其类型是数据还是函数;Bind字段表明符号是本地的还是全局的;Ndx Name字段则是符号的名称。
4.4 Hello.o的结果解析
在linux下使用命令:objdump -d -r hello.o > hello_o.txt, 得到hello.o的反汇编,重定向至hello_o.txt文件。
文件内容如下:
对应的汇编代码hello.s如下:
通过比较二者,可以发现主要具有以下区别:
1.指令的变化:在汇编码中的指令在反汇编代码中都被替换为机器码指令。对于机器码指令,前几位代表了指令的类型和寄存器编码等,后面的则为16进制表示的操作数,不过可以看到有许多指令中的操作数是0,在hello.s中则为由非0的地址确定的值,这是由于可重定位目标文件中的地址还未经过链接,不是真正的地址,故用0暂为替代操作数,实际上在经过链接获得真正的地址后这部分需要地址的指令的操作数会得到替换。
2.分支控制转移的变化:跳转语句在hello.s中的跳转是如.L3等的代码块,在反汇编代码中被替换为了相对于main函数起始位置偏移的地址。
3.函数调用表示的不同:在hello.s中,函数调用的call命令后跟随的是函数的名字,在反汇编代码中则使用相对偏移地址,需要等待链接器进行重定位才可以确定运行时的地址。
4.5 本章小结
本章简单介绍了汇编的概念以及作用。展示了在linux系统下由汇编语言程序转换为可重定位目标文件的过程,并且较为详细地分析了可重定位目标文件的结构和内容,以及其反汇编代码和hello.s中的汇编程序代码的区别。
在这一章中,可以看到汇编阶段将汇编语言转换为机器语言,生成可重定向目标文件,这一切都是在为之后的链接做准备。在经过链接过程后,将得到可执行程序——hello。
第5章 链接
5.1 链接的概念与作用
1.链接的概念:
链接是指将各种代码和数据片段收集并组合为一个单一的可执行文件的过程,链接可以执行于编译时,加载时,或者运行时。在现代系统中,链接由链接器自动执行。
2.链接的作用:
链接这一过程使得分离编译成为可能。我们可以对于同一个程序分割为若干独立模块,为其编写不同的源代码,分别独自编译为目标文件或库,最终将其链接起来。当我们改变这些模块中的一个时,只需要简单的重新编译它,并重新链接,不需要重新编译其他文件。
5.2 在Ubuntu下链接的命令
Linux链接命令: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命令获得hello的elf格式,并重定向至hello_exe_elf.txt中。
1.ELF头:
2.节头:
3.符号表:
4.程序头:
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段如下:
在图中,我们可以得出:
- 由前四行的Name属性可以看出,这四个段是hello可执行文件本身的代码和数据,对应的是5.3中程序头的四个LOAD段。
- 紧随其后的四行由Name可以得知是来自hello加载的动态链接库ld-2.31.so。
5.5 链接的重定位过程分析
使用命令:objdump -d -r hello > hello_out.txt,得到此时hello的反汇编代码。
通过分析,我们可以得到hello的反汇编代码相较于hello.o的反汇编代码主要有以下区别:
(1)打开文件即可看到,相比hello.o反汇编代码一开始即为<main>,hello.o的反汇编代码增加了新的节,如.init, .plt,如下图所示:
(2)hello.o反汇编代码中本来为0的部分待更改的操作数的值已经被修改,并且之前跳转和函数调用语句中的地址被修改为虚拟内存地址,如下图:
(3)hello反汇编代码中加入了不少主函数调用的库函数的内容,如图:
结合以上分析,可以简单总结出重定位的过程:
链接器首先组织所有模块的节,将所有类型相同的节合并在一起,然后把运行时的内存地址赋给新的合并得到的聚合节,以及输入模块定义的每个节和符号。
在重定位过程中,链接器进行符号解析,关联每个符号引用和符号定义,进行重定位时,使用汇编器产生的重定位条目,把符号定义和一个内存位置关联起来,使每条指令和全局变量拥有唯一的运行时地址。最终,就得到了一个可执行目标文件。
5.6 hello的执行流程
进入edb界面,运行程序时加入参数:
使用EDB跟踪运行流程,流程中跳转的子程序记录如下表:
子程序名 | 地址 |
ld-2.31.so!_dl_start | 0x7f8586bea770 |
ld-2.31.so!_dl_init | 0x7f8586bea9a0 |
hello!_start | 0x4010f0 |
libc-2.31.so!_libc_start_main | 0x7f634a96ce60 |
hello!printf@plt | 0x401040 |
hello!sleep@plt | 0x401080 |
hello!getchar@plt | 0x401050 |
libc-2.31.so!exit | 0x7f634a7540d0 |
5.7 Hello的动态链接分析
动态链接采用了延迟加载的策略,即在调用函数时才进行符号的映射。使用偏移量表GOT和过程链接表PLT的协同工作实现函数的动态链接。GOT中存放函数目标地址,PLT使用GOT中的地址跳转到目标函数。
程序开始后,通过执行dl_init可以修改PLT和GOT,下面是执行dl_init之前的PLT内容:
执行dl_init之后的内容:
从图中第二行可以看到变化。
5.8 本章小结
本章简单介绍了链接的概念与作用,在linux下链接的命令,可执行文件hello的格式,hello的虚拟地址空间,分析了链接的重定位过程,、和hello的执行流程,进行了hello的动态链接分析。通过对可重定位目标文件hello.o生成可执行目标文件hello的全过程的追踪与分析,展示了较为完整的链接的流程。
第6章 hello进程管理
6.1 进程的概念与作用
1.进程的概念:
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中,上下文则是由程序正确运行所需的状态组成的。每次用户通过shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并在这个新进程的上下文中运行他们自己的代码或其它应用程序。
2.进程的作用:
像hello这样的程序在现代系统上运行的时候,进程能够使操作系统提供一种假象,就像是系统上只有这个程序正在运行,程序看上去像是独占地使用处理器,主存和IO设备,处理器看上去像是不间断地一条接着一套地执行程序当中地指令,即该程序地代码和数据是系统内存当中地唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
1.作用:
shell应用程序提供了一个界面让用户能通过访问这个界面访问操作系统内核的服务。
2.处理流程:
1)从终端读入输入的命令。
2)将输入字符串切分获得所有的参数。
3)如果是内置命令则立即执行。
4)否则调用相应的程序执行。
5)shell 应该接受键盘输入信号,并对这些信号进行相应处理。
6.3 Hello的fork进程创建过程
在键入命令后,shell对其进行解析,发现是加载并执行一个可执行文件,故父进程通过调用fork函数创建一个新的运行的子进程,子进程得到和父进程用户级虚拟地址空间相同的(但是独立的)一个副本。还获得与父进程任何打开文件描述符相同的副本。父进程和新创建的子进程最大的不同在于它们具有不同的PID。fork函数调用一次,返回两次,在父进程中,fork返回子进程的PID,在子进程中,返回0。示意图如下:
6.4 Hello的execve过程
execve 函数加载并运行可执行目标文件hello, 且带参数列表argv 和环境变量列表envp。只有当出现错误时,例如找不到可执行目标文件, execve 才会返回到调用程序,与fork 一次调用返回两次不同,execve 调用一次并从不返回。
execve函数加载hello后,调用启动代码。启动代码设置栈,并将控制传递给新程序的主程序,主函数原型如下:
int main(int argc, char **argv, char *envp)
6.5 Hello的进程执行
1.上下文信息:
进程的上下文信息是指程序正确运行所需的状态,这个状态包括存放在内存中的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。
2.逻辑控制流:
即使在系统中通常有许多其他程序正在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果使用调试器单步调试执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,简称逻辑流。
3.并发流:
一个逻辑流的执行在时间上与另一个逻辑流重叠,成为并发流。这两个流被称为并发地运行。多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念称为多任务,一个进程执行它的控制流的每一时间段叫做时间片。因此,多任务也叫做时间分片。
4.用户模式和内核模式:
内核模式和用户模式是一个进程的不同模式,由模式位来控制。
当设置了模式位时,进程就运行在内核模式中,这时候这个进程就可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。运行程序代码的进程一开始是处于用户模式,只有当发生中断、故障或者陷入系统调用这样的异常时,转而去执行异常处理程序,这时进程才会变为内核模式。当它返回到应用程序代码时,处理器就把模式从内核模式改为用户模式。
5.上下文切换:
内核为每个进程维持一个上下文。在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度。是由内核中被称为调度器的代码处理的。当内核选择了一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。
上下文切换:1)保存当前进程的上下文,2)回复某个先前被抢占的进程被保存的上下文,3)将控制传递到这个新恢复的进程。
6. hello程序进程执行图示如下:
6.6 hello的异常与信号处理
1.hello执行过程中的异常及处理:
(1)中断:
中断是异步发生的,是来自处理器外部I/0设备的信号的结果,中断处理程序会将控制返回给应用程序控制流中的下一条指令,图示如下:
(2)陷阱和系统调用:
陷阱是有意的异常,是执行一条指令的结果,就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口。图示如下:
(3)故障:
故障是由错误情况引起的,可能被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序,如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它,否则,处理程序返回到内核中的abort例程,它会终止引起故障的应用程序。图示如下:
2. linux下发生异常的结果:
(1)乱按和回车:
(2)ctrl-z:
(3)ctrl-c:
(4)ctrl+z后续jobs:
(5)ctrl+z后续pstree(部分):
(6)ctrl+z后续fg:
(7)ps:
(8)kill以及后续ps:
6.7本章小结
本章简单介绍了进程的概念和作用,shell的作用和流程等,并较为详细的说明了hello进程的创建,加载,执行,终止的整个流程,介绍了进程执行过程中的一些实现细节和hello进程的执行流程。并分析了进程执行过程中的异常及其处理。
从中我们可以看出,进程执行时独占cpu和整个内存空间只是假象,实际上通过操作系统的进程调度机制该进程是在与其他进程并发进行。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.物理地址:
加载到内存地址寄存器中的地址,内存单元的真正地址。CPU通过地址总线的寻址,找到真实的物理内存对应的地址。在前端总线上传输的内存地址都是物理内存地。
2.逻辑地址:
是指由程序产生的与段相关的偏移地址部分,它表示为[段标识符:段内偏移量]的形式,由一个段标识符再加上段内相对地址的偏移量构成。
3.虚拟地址:
虚拟地址是CPU启动保护模式后,程序访问存储器所用的逻辑地址。
4.线性地址:
线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址然后加上基地址就是线性地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由[段标识符: 段内偏移量]两部分组成。段标识符由一个16位长的字段组成,称为段选择符。前13位是一个索引号,后面3位包含一些硬件细节,表示具体的是代码段寄存器还是栈段寄存器还是数据段寄存器。
索引号即“段描述符(segment descriptor)”的索引,段描述符具体地址描述了一个段,多个段描述符组成“段描述符表”,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符。Base字段表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。全局的段描述符,放在全局段描述符表(GDT)中,局部的如每个进程自己的,就放在的局部段描述符表(LDT)中。
通过段描述符得到段的基址,与段内偏移地址相加得到的64位整数就是线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
各进程的虚拟地址空间会被划分为若干页,内存空间按页的大小建立页表。
页表就是一个页表条目数组,虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个页表条目。它由一个有效位和n个字段组成。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。页表图示如下:
对于一个线性地址,它的表示方式为:
CPU通过将逻辑地址转换为虚拟地址来访问主存,这个虚拟地址在访问主存前需要先转换成适当的物理地址。
对于一个线性地址,会通过它的虚拟页号在页表里查询对应的页表条目,得到物理页号以及虚拟页偏移量,组合形成物理地址。之后CPU会通过这个物理地址来访问物理内存。
7.4 TLB与四级页表支持下的VA到PA的变换
1. TLB:
CPU每次产生一个虚拟地址,内存管理单元MMU都需要查询一个页表条目。在最差情况下会从内存中多取一次数据,代价将高达几十到几百个周期。如果页表条目PTE缓存在L1中,开销会下降到1-2个周期。许多系统试图消除这样的开销,所以在MMU中包含了一个关于PTE的小的缓存,即翻译后备缓存器TLB。
2.多级页表:
多级页表是用于压缩页表的层次结构。从两个方面减少内存要求:1.一级页表中如果有PTE为空,二级页表就不存在。2.一级页表需要总是在主存中,虚拟内存系统在需要时创建,页面调出或调入二级页表,减少主存压力。
3.VA到PA的变换:
以课本上Core i7地址翻译为例:
Core i7采用四级页表的层次结构,每个进程有它自己私有的页表层次结构。CPU产生虚拟地址,这个虚拟地址传送给内存管理单元MMU,MMU使用虚拟页号的前36位在缓存页表中寻找匹配。如果命中,则得到物理页号与虚拟页偏移量组合成的物理地址。如果没有命中,则MMU查询页表,确定第一级页表的起始地址,图中VPN1确定在第一级页表中的偏移量,查询出页表条目,如果在物理内存中且权限符合,则确定第二级页表的起始地址。以此类推,最终在第四级页表中得到组合成的物理地址。
图示如下:
7.5 三级Cache支持下的物理内存访问
当CPU对一个物理地址进行访问的时候,根据PA,L1高速缓存的组数和块大小确定高速缓存块偏移(CO),组索引(CI)和高速缓存标记(CT),利用CI进行组索引,对每组的8行分别匹配CT,若匹配成功且块的valid标志位为1,则命中,根据CO取出数据后返回。
若未找到匹配的行或有效位为0,则L1不命中,查找下一级缓存L2 Cache,不命中则查找L3,若三级Cache里都没有对应内存块,那么CPU将会直接访问物理内存。当至少有一级高速缓存未命中时,需要在得到数据后更新未命中的Cache。判断其中是否有有效位为0的块,如果有则可以直接将数据写入,否则则需要根据LRU,LFU等替换策略驱逐一个块后再写入。
7.6 hello进程fork时的内存映射
当shell使用fork创建子进程时,内核为新的子进程创建各种数据结构,并分配给子进程一个唯一的PID,为了给它创建虚拟内存空间,内核创建了当前进程的mm_struct、区域结构和页表的原样副本,将两个进程的页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当这两个进程中的任意一个进行写操作时,写时复制机制就会创建新页面,这样就可以为两个进程保持地址空间在逻辑上的私有。
7.7 hello进程execve时的内存映射
execve函数加载和运行hello需要以下几个步骤:
1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域: 新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的.代码和数据区域被映射为hello文件中的.text和.data区。.bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零.
3.映射共享区域: hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内.
4.设置程序计数器(PC)execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
加载器映射用户地址空间区域图示如下:
7.8 缺页故障与缺页中断处理
一般将DRAM缓存不命中称为缺页,如果程序执行过程中遇到了缺页故障,则内核调用缺页处理程序。流程如下图:
处理程序会选择一个牺牲页,如果该页面被修改过,内核会从磁盘复制引发缺页异常的页面至物理内存,更新页表,随后返回,将控制转移给hello进程。再次执行触发缺页故障的指令,这个指令会将导致缺页的虚拟地址重新发送到地址翻译硬件。
7.9动态存储分配管理
动态内存分配管理使用动态内存分配器进行。动态内存分配器维护一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。空闲块保持空闲直至被应用程序分配,已分配块保持已分配状态直至被释放。这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,即显式分配器和隐式分配器。显式分配器要求应用显式地释放任何已分配的块,具有以下约束条件:1.处理任意的请求序列。2.立即响应请求。3.只使用堆。4.对齐块。5.不修改已分配的块。隐式分配器要求分配器检测一个已分配块何时不再被程序使用时再释放这个块。
堆中内存块主要组织为两种形式:
1.隐式空闲链表:一个块由一个字的头部,有效载荷,以及可能的填充组成。头部编码这个块的大小以及这个块是否已分配。头部后是应用malloc时请求的有效载荷,之后是一块大小任意的不使用的填充块。空闲块通过头部中大小字段隐含地连接着,称这种结构为隐式空闲链表,可以添加边界标记提高合并空闲块的速度。
2.显式空闲链表:在隐式空闲链表结构基础上,在每个空闲块中添加两个指针,分别指向前一个和后一个空闲块。
7.10本章小结
本章简单介绍了hello的存储器地址空间,intel的段式管理和页式管理,TLB与四级页表支持下VA到PA的变换,三级cache支持下物理内存访问,hello进程fork和execve时的内存映射,缺页故障与缺页中断处理以及动态存储分配管理等内容。表现出了现代计算机应用了许多机制和技术来进行性能的提升。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
1.设备的模型化:所有IO设备(网路、磁盘、终端等)都被模型化为文件,所有的输入和输出都能被当做相应文件的读和写来执行。
2.设备管理:将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的接口,称为Unix I/O,是的所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
1.Unix IO接口:
1.打开文件:一个应用程序要求内核打开相应的文件,来宣告它想要访问一个IO设备,内核返回一个小的非负整数描述符,它在后续对此文件所有操作中标识这个文件。内核记录有关这个打开文件的所有信息,应用程序只需要记住这个描述符。
2.Shell创建的每个进程开始时都有3个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。
3.改变当前的文件位置:对于每个打开的文件,内核保持一个文件位置k,初始为0,该文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
4.读写文件:一个读操作就是从当前位置k开始,从文件复制n>0个字节到内存,然后将k增加到k+n,给定一个文件,当k超出文件长度时执行读操作会触发EOP条件,应用程序能够检测到这个条件。类似的,写操作则是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5.关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件,作为响应,内核释放文件打开时创建的数据结构和内存资源,并将这个描述符恢复到可用的描述符池中。
2.Unix IO函数:
1. int open (char *filename, int flags, mode_t mode);
功能:函数打开一个文件,将filename转换为一个文件描述符,若成功则返回描述符数字,出错返回-1。flags参数指明进程如何访问文件,mode参数指定新文件的访问权限位。
2.int close (int fd);
功能:函数关闭一个打开的文件,fd 是需要关闭的文件的描述符,close 返回操作结果,成功为0,失败为-1。
3.ssize_t read (int fd, void *buf, size_t n);
函数从描述符为fd的当前文件位置复制最多n个字节到内存位置 buf。返回值-1表示一个错误,返回0表示 EOF,否则,返回值表示的是实际传送的字节数量。
4.ssize_t wirte (int fd, const void *buf, size_t n);
函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
1.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; } |
从中可以看出,printf函数调用vsprintf函数,按照格式fmt结合参数arg生成格式化后的字符串,最后通过系统调用函数write输出,并返回字符串的长度。
2.printf调用的vsprintf函数:
int vsprintf(char *buf, const char *fmt, va_list args) { char *p; chartmp[256]; va_listp_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; … … //还有其他情况的处理,内容较多,此处省略 default: break; } return (p – buf); } } |
从代码可以看出,vsprintf函数的主要作用为格式化,它接受一个格式字符串fmt来限制格式,对参数进行格式化,将产生的输出写入buf中。该函数返回要打印的字符串的长度。
3.write系统调用:
write: mov eax, _NR_write mov ebx, [esp + 4] mov ecx, [esp + 8] int INT_VECTOR_SYS_CALL |
write系统调用通过寄存器进行传参,先将栈中参数存入寄存器,ebx存放第一个字符的地址,ecx代表字符个数,随后调用中断门int INT_VECTOR_SYS_CALL,表示通过系统调用syscall,实现输出。
4.sys_call函数:
sys_call: xor ai,si mov ah,0Fh mov al,’\0’ je .end mov [gs:edi],ax inc si loop: sys_call .end: ret |
从代码分析可得,该函数打印字符,遇到’\0’停止。其中,[gs:edi]对应0x80000h:0,采用直接写显存的方式显示字符串。
write执行syscall指令调用系统服务,从而使内核执行打印操作,内核通过字符显示驱动子程序根据传入的ASCII码到字模库读取字符对应的点阵,通过vram(即显存,存储每一个点的RGB颜色信息)对字符串进行输出,显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。最终实现prinf中字符串在屏幕上的输出。
8.4 getchar的实现分析
1.一种getchar的代码实现如下:
int getchar(void) { static char buf[size]; static char* bb=buf if(n==0) { n=read(0,buf,size); bb=buf; } return(--n>=0)? (unsigned char) *bb++ :EOF; } |
分析代码可以看出:
主体中,getchar函数开辟静态输入缓冲区,如果缓冲区为空,调用read函数来读取字符。对于read函数,其一个参数为文件描述符fd(fd为0表示标准输入);第二个参数为输入内容的指针;第三个参数为读入字符的个数。
当程序调用getchar时,程序等待用户的键盘输入,用户输入的字符被存放在键盘缓冲区中。直到用户键入回车之后,getchar通过系统调用read从输入缓冲区每次读入一个字符。该函数返回用户输入的第一个字符的ASCII码,出错返回-1。
2.异步异常-键盘中断的处理:
调用键盘中断处理子程序,它会接受按键扫描码转成ASCII码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ASCII码,直到接受到回车键返回。
8.5本章小结
本章简单介绍了Linux系统的IO管理,简单介绍了unix的IO接口及函数,分析了printf和getchar的实现,并通过这些分析展示了程序是如何接收用户键入的内容并在屏幕上打印信息的,展现了IO管理的强大功能。
结论
1.hello程序实现p2p的过程:
1.编写:用c语言编写hello.c源代码。
2.预处理:预处理器处理hello.c,将所有调用的外部库展开,合并,生成hello.i文件。
3.编译:编译器将hello.i转换为汇编语言文件hello.s。
4.汇编:汇编器将hello.s文件转换为二进制形式的可重定位目标文件hello.o。
5.链接:链接器将hello.o与其它库进行链接并进行重定位,生成可执行文件hello。
6.创建子进程:用户在shell中输入执行hello的命令后,shell通过fork创建进程。
7.加载:shell调用execve,在上下文中加载可执行程序hello,开始执行hello。
8.执行:hello程序运行,在此过程中,可能产生异常与信号,例如用户键盘输入ctrl+z,ctrl+c等,需要调用信号处理程序进行处理。在执行程序的过程中,hello还将利用各种复杂的机制进行内存访问。
9.终止:hello程序结束运行或收到信号后终止,父进程结束并回收hello子进程,内核删除内存中为hello创建的相关数据。
2.感悟:
通过对linux系统下hello的P2P整个流程的追踪,不禁令人感受到现代计算机功能的强大和机制的复杂,执行hello程序看上去非常简单,之前我在写程序的时候也只是写出代码然后直接运行,从未探究过它是如何在计算机中实现的。但在这次大作业中,从原代码到可执行程序再到运行,在经历并探究整个过程的实现和各种底层机制,了解实现过程中软硬件之间的相互配合后,不得不令人感叹想要实现这个程序真是一个浩大的工程!小小的hello程序的实现浓缩并体现着研究设计计算机系统的技术人员的智慧与心血。
附件
文件名 | 说明 |
hello.c | C语言源程序 |
hello.i | hello.c预处理的文本文件 |
hello.s | hello.i编译后的汇编文件 |
hello.o | hello.s汇编后的可重定位文件 |
hello_elf.txt | Hello.o经过readelf分析得到的文本文件 |
hello_o.txt | hello.o经过objdump反汇编后得到的文本文件 |
hello | 链接后的可执行文件 |
hello_exe_elf.txt | hello经readelf分析得到的文本文件 |
hello_out.txt | hello经过objdump反汇编后得到的文本文件 |
参考文献
[1] Randal E,Brynant, David R. O’Hallaron. 深入理解计算机系统(原书第三版). 北京:机械工业出版社,2016.
[2] Linux 操作系统原理—内存—页式管理、段式管理与段页式管理.代码天地.
Linux 操作系统原理 — 内存 — 页式管理、段式管理与段页式管理 - 代码天地
[3] c/c++中printf函数的底层实现.CSDN博客.
c/c++中printf函数的底层实现_scut_yp的博客-CSDN博客_printf的底层实现
这篇关于哈工大计算机系统大作业 程序人生 Hello‘s P2P的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!