计算机系统大作业

题目 程序人生-Hello’s P2P

专业计算机类

学号 120L021428

班级 2003002

学生 范会明

指 导 教 师 史先俊

计算机科学与技术学院

2022年5月

摘 要

本文旨在研究hello.c的整个生命周期。利用gcc,edb-debugger1.3.0,等工具对于Hello程序的进程进行分析,分析了预处理、编译、汇编、连接等各个过程的实现原理和实现结果,借助CSAPP教材上讲述的知识和在实验中学到的技能,分析了hello在运行过程中涉及的进程管理、内存管理、IO管理到最后hello被回收的整个过程,做到了更深入理解计算机系统

关键词:CSAPP hello程序 P2P 进程管理 预处理 编译 汇编 连接 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 本章小结…………………………………………………………………………………………… – 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,指From Program to Process

Program:首先我们编写好.c文件,这个过程就是program,编译器驱动程序代表用户在需要时调用语言预处理器、编译器、汇编器和链接器,预处理器将原始代码处理生成.i文件,编译器将其编译得到.s文件,汇编器将.s文件进一步处理,翻译成机器语言,将指令打包为可重定位的.o文件,最后,连接器将它与库函数进行连接,得到可执行文件hello

Progress:在终端中./hello后,操作系统fork一个子进程hello来执行这个程序,execve加载这个程序。

020,指From Zero-0 to Zero-0

操作系统调用了execve来加载进程,首先映射虚拟内存空间,删除当前的虚拟地址,为hello重新进行一次内存映射,执行结束之后,hello进程被回收,hello进程从开始没有运行到运行后空间被彻底回收,就是020的整个过程了。

1.2 环境与工具

硬件环境:X64 CPU2GHz4GRAM.

软件环境:Windows11 64位;Vmware 16.0Ubuntu 20.04.

使用工具:Codeblocks; Objdump; edb-debugger; Hexedit.

1.3 中间结果

hello.i hello的预处理结果

hello.s hello.i的汇编结果

hello.o hello.s翻译成的可重定位文件

hello 可执行文件

helloo.obj hello.o的反汇编结果

helloo.elfhello.o的ELF格式文件

hello.obj hello的反汇编结果

hello.elfhello的ELF格式文件

1.4 本章小结

有这样一个笑话,程序员对世界说:我学习编程的奥妙只为了向你问好,在我看来,hello虽然只是最初的,最简单的程序,是第一次点击CodeBlock的输出结果,只是一个简单的入门程序,但它却是一个最完美的过程,计算机的软件,硬件,各种程序在系统中完美配合,生动而美妙

本章主要介绍了hello的P2P,020过程,对实验中所生成的文件进行了简单的介绍。

第2章 预处理

2.1 预处理的概念与作用

定义: 程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程

最常见的预处理是C语言和C++语言。ISO C和ISO C++都规定程序由源代码被翻译分为若干有序的阶段(phase),通常前几个阶段由预处理器实现。预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。

大多数预处理器指令属于下面3种类型:

1、宏定义:#define 指令定义一个宏,#undef 指令删除一个宏定义。

2、文件包含:#include指令导致一个指定文件的内容被包含到程序中。

3、条件编译:#if,#ifdef#ifndef,#elif#else #endif 指令可以根据编译器可以测试的条件来将一段文本包含到程序中或排除在程序之外。

合理地使用预处理功能编写的程序便于阅读、修改、移植和调试,也有利于模块化程序设计。

2.2在Ubuntu下预处理的命令

Linux中hello.c文件进行预处理的命令是:gcc -E -o hello.i hello.c

使用这个指令后,生成了名为hello.i的文件

2.3 Hello的预处理结果解析

继续使用指令:gedit hello.i来打开hello.i程序并查看,发现得到了一个3060行的程序,其中原本的main函数在3047行,而前边的include指令都被取代,这些头文件:stdio.h nistd.h stdlib.h在预处理过程中被取代,此时,可以在VS2019中打开stdio.h

观察stdio.h,可以发现其中有更多的#include内容,而这些内容不存在于hello.i中,也就是说这些文件也被替换,stdio.h程序中还有类似#ifndef的指令,用以判断在预处理的过程中进行怎样的处理,可以看出,预处理的程序仍是C语言的源程序,可以正常的阅读,只是将源程序做了一些简单的处理

2.4 本章小结

本章对hello.c进行了预处理,并将预处理的结果进行分析,介绍了预处理的定义、作用以及处理方式包括宏替换、头文件引入、删除注释、条件编译等

第3章 编译

3.1 编译的概念与作用

概念:广义的编译是说将某一种程序设计语言写的程序翻译成等价的另一种语言。此处是指利用编译程序从预处理文本文件(.i)产生汇编程序(.s)的过程。

流程: 编译包括词法分析,语法分析,中间代码,代码优化,目标代码等过程,在这个过程中高级语言被翻译成汇编语言,既可以被程序员理解,又更接近机器语言,是源程序转换成机器语言的关键环节。

3.2 在Ubuntu下编译的命令

命令:gcc -S hello.i -o hello.s

Hello.s的部分结果

3.3 Hello的编译结果解析

3.3.1数据处理

3.3.1.1常量处理

Hello.c中两个printf中的字符串为字符常量,观察hello.s,可以看到这两个常量被.lc0和.lc1标识,如图

下方的.string声明了一个字符串类型的常量,第三行的.rodata表示这是只读数据段

3.3.1.2局部变量

Hello.c中只有一个局部变量,int i = 0,在汇编程序中.L2处定义了这个变量并且将这个变量存在了栈空间上

3.3.1.3函数参数

argc和argv和变量i一样,被保存在栈空间上,访问时通过栈地址加偏移量进行访问,如图

3.3.2数据操作

3.3.2.1数据赋值

程序中定义变量i的时候对它进行了赋值为零,由于i是int型变量,会占用四个字节,所以使用了movl指令,左侧的$0是立即数0

3.3.2.2数据加

循环中变量执行i++,同样,由于int类型占四字节,所以使用addl

此外,还有一些关于rax寄存器的加法指令

可以看出,首先汇编指令将栈指针传递给了rax,然后对它进行加减,这个过程的意义是寻址,由于此处保存的argc,argv将会在printf中被使用,所以需要加载传输需要的数据。

3.3.2.3数据比较

(1)argc!=4,这是在判断程序的输入是否合理,来确定是否执行下一步操作,在汇编中使用cmpl来判断和跳转

​​​​​​​

(2)i<8,这是循环变量的判断,为下边的跳转做出准备,同样使用了cmpl指令,这个指令的比较结果是jle跳转的依据。

3.3.3函数操作

3.3.3.1调用函数

main函数中调用了puts,exit,getchar,print,atoi,sleep等函数,如图

3.3.3.2数据准备

第一个printf转换成了puts,把.L0段的立即值传入%rdi,然后call跳转到puts。这里的exit是把立即数1传入到%edi中,然后call跳转到exit

第二个printf有三个参数,第一个是.LC1中的格式化字符串%eax中,后面的两个依次是%rdi,%rsi,然后跳转到printf

sleep有一个参数传到%edi中,之后call跳转到 sleep中

getchar不需要参数,直接call跳转即可。

3.3.3.3函数返回

编译时,在函数的最后添加指令ret来实现函数的返回。在hello.c这个例子中,只能看到main函数的返回。如图:

3.3.4控制转移

上图的跳转是argv与4比较过后,若argv等于4,则会执行程序段L2的代码

这里只是对i赋值之后正常跳转到循环比较中

上图的跳转是循环的跳转,当i小于等于7时再次执行循环体

3.4 本章小结

这一章分析了在编译过程中编译器到底做了什么,从数据处理,数据操作,函数操作,控制转移四个部分对编译结果进行分析,建立了C源代码和hello.i中各种操作之间的映射关系,帮助我了解了编译器是怎样理解我所编写的程序,在编译之后,我所写的C代码被解构成了更低级的汇编语言。

第4章 汇编

4.1 汇编的概念与作用

概念:将编译生成的ASCII汇编语言文件hello.s翻译成一个可重定位目标文件hello.o。可重定位目标文件包含指令对应的二进制机器语言,这种二进制代码能够被计算机理解并执行。因此汇编是将汇编语言转换成最底层的、机器可理解的机器语言的过程。

作用: 将汇编代码转换为机器指令,使其在链接后能被机器识别并执行

4.2 在Ubuntu下汇编的命令

命令:gcc -c -o hello.o hello.s

4.3 可重定位目标elf格式

分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。

使用 readelf -a hello.o > helloo.elf 指令

得到了helloo.elf文件,打开文件进行进一步分析。

首先是ELF头

(1)ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。以hello.o为例,这个16字节序列为7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00

ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。

(2)节头: 包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。以hello.s为例,节头部表一共描述了13个不同节的位置、大小等信息。

1.text节,大小为0x92,类型为PROGBITS,标志为AX(只读可执行),偏移量为0X40,

2.rela.text节,大小为0xc0,类型为RELA,标志为I,偏移0x380字节

3.data节,已初始化的全局变量节,类型为PROGBITS,标志为WA(可读写),偏移0xd2

4.bss节,未初始化的全局和静态变量或初始化为0的全局或静态变量,大小为0,类型为PROGBITS,标志为WA,偏移0xd2

5.rodata节,只读数据,大小为0x33,标志为A(只读),偏移0xd8

6.comment节,版本控制信息,大小为0x24,标志为MS,偏移为0xef

7.note.GNU_stack节:标记可执行堆栈,大小为0x0字节,类型为PROGBITS,偏移量为0x12f

8.eh_frame节:处理异常,大小为0x38字节,类型为PROGBITS,偏移量为0x150,标志为A(表明该节的数据只读)。

9.rela.eh_frame节:.eh_frame节的重定位信息,大小为0x18字节,类型为RELA,偏移量为0x440,标志为I。

10.shstrtab节:包含节区名称,大小为0x61字节,类型为STRTAB,偏移量为0x188

11.symtab节:一个符号表,存放在程序中定义和引用的函数和全局变量的信息。大小为0x1b0字节,类型为SYMTAB,偏移量为0x338

12.strtab节:一个字符串表,包括.symtab和.debug节中的符号表,以及节头,偏移量为0x458字节

13shstrtab节:大小为0x74字节,也是一个字符串表,偏移量为0x458字节

(3)重定位节:包含text节中需要重定位的信息,当链接器将这个文件和其它文件链接时,这个位置的信息需要更新,也就是所说的重定位过程,八条重定位函数,分别对于.L0,puts,exit,.L1,printf,atoi,sleep,getchar进行重定位。

每个重定位条目包括offset:需要被修改的引用的节偏移;symbol:标识被修改引用应该指向的符号;type:重定位类型,告知链接器如何修改新的引用;attend:一些重定位要使用它对被修改引用的值做偏移调整。ELF定义了32种不同的重定位类型,两种最基本的重定位类型包括R_X86_64_PC32(重定位使用32位PC相对地址的引用)和R_X86_64_32(重定位使用32位绝对地址的引用)。

(4)符号表: 目标文件的符号表中包含用来定位、重定位程序中符号定义和引用的信息。符号表索引是对此数组的索引。索引 0 表示表中的第一表项,同时也作为定义符号的索引。

4.4 Hello.o的结果解析

使用 objdump -d -r hello.o > helloo.objdump

如图,经过与hello.s的比较后发现,二者整体上差别不大,都可以正常阅读理解,主要区别有以下几点:

1.分支转移,hello.s的分支转移采用的是助记符L0,L1这样的形式,而机器码由于需要直接被电脑阅读,不能采用助记符而是采用了直接跳转到制定地址,比如

这表示直接跳转到main函数偏移0x20的地址处。

2.函数调用,在hello.s文件中函数调用直接表达为call加函数名,而机器码中函数名则应该向分支转移那样被地址所取代,但由于未经过重定位,这些地址目前都是000000,经过重定位之后得到的结果这些地址才会变成函数的地址,如图:

3.全局变量访问形式,同样,全局变量的访问也不能直接访问,需要访问.rodata文件中的内容,这个过程在重定位之前和函数一样,不能确定访问的地址,所以暂时仍然使用000000作为地址。

4.立即数的访问,立即数在汇编之后变成机器所能阅读的二进制数,在obj文件中以16进制数显示,而.s文件中是使用10进制数来表示。

4.5 本章小结

本章介绍了汇编的过程,经过汇编器,汇编语言转化为极其语言,分析了ELF文件的内容,另外比较了重定位前汇编程序和重定位后反汇编的差别,了解从汇编语言翻译成机器语言的转换处理和机器语言和汇编语言的映射关系

第5章 链接

5.1 链接的概念与作用

概念:链接(linking)是将各种代码和数据片段收集并合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(run time),也就是在由应用程序来执行。

作用:链接器在软件开发过程中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。

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 > hello.elf

(1)elf头: 上次的节头数量为13个,这次变为27个,其它保持一致。

(2)节头: 各节的起始地址由偏移量给出,同时也给出了大小等信息

(3)程序头: 程序头部表描述了可执行文件的连续的片映射到连续的内存段的映射关系。包括目标文件的偏移、段的读写/执行权限、内存的开始地址、对齐要求、段的大小、内存中的段大小等。

以第一个LOAD为例,Offset说明段的偏移量为0;

VirtAddr说明映射到的虚拟内存段的开始地址是0x400000;

FileSiz说明段的大小为0x720字节;

Memsiz说明内存中的段大小也是0x720字节;

Flags为R E,标志段的权限为只读且可执行;

Align说明段的对齐要求为200000;

(4)段节

1. Dynamic section如果目标文件参与动态链接,则其程序头表将包含一个类型为 PT_DYNAMIC 的元素。此段包含 .dynamic 节。特殊符号 _DYNAMIC 用于标记包含以下结构的数组的节

2. 重定位节.

在hello 中,原来的.rela.text节已经没有了,说明链接的过程已经完成了对.rela.text的重定位操作。Hello中出现了6个新的重定位条目。这些重定位条目都和共享库中的函数有关,因为此时还没有进行动态链接,共享库中函数的确切地址仍是未知的,因此仍然需要重定位节,在动态链接后才能确定地址。

3.符号表: 目标文件的符号表包含定位和重定位程序的符号定义和符号引用所需的信息。每个符号表是一个条目的数组,每个条目包括value:距定义目标的节的起始位置的偏移;size:目标的大小;type:指明数据还是函数;bind:表示符号是本地的还是全局的等等。相比于hello.o,hello有51个符号,多出了一些产生的库函数和必要的启动函数。

同时,hello中还有一个动态符号表

5.4 hello的虚拟地址空间

使用edb –-run hello运行hello

使用edb加载hello,可以看到进程的虚拟地址空间各段信息。可以看出,段的虚拟空间从0x400000开始,到0x400ff0结束。

可以看到,虚拟地址为0x40000-0x400ff0,根据节头可以知悉各个节的信息,并且用edb查看,例如.text节于4010f0开始,如图

代码刚好从4010f0开始,在401244结束

再例如.data节位于004048处,如图

5.5 链接的重定位过程分析

使用 objdump -d -r hello > hello.objdump

hello与hello.o的不同之处在于以下两个方面:

1. hello中的汇编代码已经使用虚拟内存地址来标记了,从0x400000开始;而hello.o中的汇编代码是从0开始的,还没有涉及到虚拟内存地址,但经过链接,这些地址被确定下来了。

2. 在hello.o中,只存在main函数的汇编指令;而在hello中,由于链接过程中引入了其他库的各种数据和函数,因此hello中除了main函数的汇编指令外,还包括大量其他的函数和指令,hello比起hello.o多出很多节: 例如.interp .note.ABI-tag .note.gnu.build-i:编译信息表.gnu.hash:gnu的扩展符号hash表.dynsym:动态符号表.dynstr:动态符号表中的符号名称.gnu.version:符号版本.gnu.version_r:符号引用版本.rela.dyn:动态重定位表.rela.plt:.plt节的重定位条目.init:程序初始化.plt:动态链接表.fini:程序终止时需要的执行的指令.eh_frame:程序执行错误时的指令.dynamic:存放被ld.so使用的动态链接信息.got:存放程序中变量全局偏移量.got.plt:存放程序中函数的全局偏移量.data:初始化过的全局变量或者声明过的函数。

重定位分析:

main函数中涉及重定位的指令的二进制代码被修改。在之前汇编的过程中,汇编器遇到对最终位置未知的目标引用,会产生一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。因此在链接的过程中,链接器会根据重定位条目以及已知的最终位置对修改指令的二进制码,这个过程就是重定位的过程。下面以hello.o为例,说明hello如何进行重定位。

这里涉及到两种不同的重定位类型,分别是R_X86_64_PC32(重定位使用32位PC相对地址的引用)和R_X86_64_32(重定位使用32位绝对地址的引用)。对于第一种重定位类型,以第一个条目为例,第一个条目的信息说明需要重定位的位置在.text中偏移量为0x1b的地方。在hello.o中找到相应的位置。

Bf00000000这条指令的目的是传一个数据,这个数据应该是字符串常量,但它的最终定位未知,所以需要重定位,将地址改为绝对地址。

对于第二种类型,类似e800000000这条指令,这是为了调用函数,地址为puts的相对低者。

5.6 hello的执行流程

进入函数,可以发现首先调用了ld-2.27.so!_dl_start,经过stepover,进入ld-2.27.so!_dl_init,继续点击F7,程序通过jmp进入Hello!_start

在start中call进入了libc-2.27.so!__libc_start_main

之后在_libc_start_main函数中进入Hello!main

在main中顺序调用了一系列函数

Hello!printf@plt

hello!atoi@plt

Hello!sleep@plt

hello!getchar@plt

最终,调用到libc-2.27.so!exit,退出程序。

5.7 Hello的动态链接分析

在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件hello。此时共享库中的代码和数据没有被合并到hello中。加载hello时,动态链接器对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。

动态链接采用了延迟加载的策略,即在调用函数时才进行符号的映射。使用偏移量表GOT+过程链接表PLT实现函数的动态链接。GOT中存放函数目标地址,为每个全局函数创建一个副本函数,并将对函数的调用转换成对副本函数调用。

上图为do_init之前,地址都是空的

上图为do_init之后,可以看到这些地址都有了相应的填充。

5.8 本章小结

本章概括了链接的概念与作用,并且详细分析了hello.o是怎么链接成为一个可执行目标文件的过程。介绍了hello.o的ELF格式和各个节的含义,并且分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。通过可执行文件的程序头来分析重定位的过程,并解析了一个程序运行的全过程。最后简单介绍了动态链接这一现代计算机中极为重要的部分是怎么运作的。

第6章 hello进程管理

6.1 进程的概念与作用

概念:进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。

​​​​​​​作用:进程作为一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令。

6.2 简述壳Shell-bash的作用与处理流程

shell是指为使用者提供操作界面的软件,是一个交互型应用级程序,它接收用户命令,然后调用相应的应用程序。shell是系统的用户界面,提供了用户与内核进行交互操作的接口。

shell的作用:shell最重要的功能是命令解释,可以说shell是一个命令解释器。Linux系统上的所有可执行文件都可以作为shell命令来执行,同时它也提供一些内置命令。此外,shell还包括通配符、命令补全、命令历史、重定向、管道、命令替换等很多功能。

处理流程:从终端读入输入的命令。将输入字符串切分获得所有的参数,如果是内置命令则立即执行,否则调用相应的程序为其分配子进程并运行,shell应该接受键盘输入信号,并对这些信号进行相应处理

6.3 Hello的fork进程创建过程

如图,输入以下指令,由于hello不是内部命令,这时候shell会后fork一个子进程,父进程会给子进程复制一份相同的虚拟地址空间副本,子进程可以读取父进程中打开的任何文件。

6.4 Hello的execve过程

execve的功能是在当前进程的上下文中加载并运行一个新程序。在执行fork得到子进程后随即使用解析后的命令行参数调用execve,execve调用启动加载器来执行hello程序。加载器执行的操作是,加删除子进程现有的虚拟内存段,并创建新的代码、数据、堆和栈段。代码和数据段被初始化为hello的代码和数据。堆和栈被置空。然后加载器将PC指向hello程序的起始位置,即从下条指令开始执行hello程序。

execve在运行时需要以下四个步骤:删除已经存在的用户区域、映射私有区域、映射共享区域、设置程序计数器。

6.5 Hello的进程执行

上下文的概念: 上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

上下文切换: 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。

开始Hello运行在用户模式,收到信号后进入内核模式,运行信号处理程序,之后再返回用户模式。运行过程中,cpu不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用cpu,实现进程的调度。

6.6 hello的异常与信号处理

正常执行hello,程序完成被正常回收。

按下ctrl-Z,中断程序

这时输入PS命令来查看进程情况

发现hello没有结束,而是暂时被挂起,PID为23557

使用job指令查看

使用pstree,可以看到hello程序的具体位置

使用fg 1将它调到前台继续执行

可以看到程序首先打印了指令,然后继续执行中断的程序,这时候程序已经被回收

重新执行程序,按下ctrl-C

可以看到进程直接被终止,hello进程被回收

在执行程序中乱按,按下的东西被存在stdin中,一部分被直接输出未作出反应。另一部分由于按下回车被当做指令处理了一次,但是是无意义指令

6.7本章小结

本章介绍了计算机中最重要的概念之一——进程。可以说现代计算机离不开进程的概念,只有充分了解进程的创建与异常流控制,才可能成为一个优秀的程序员。本章介绍了Shell的一般处理流程,调用 fork 创建新进程,调用 execve 执行 hello,hello的进程执行,hello 的异常与信号处理。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:逻辑地址是指由程序产生的与段相关的偏移地址部分。例如,在进行C语言指针编程中,可以使用&操作读取指针变量的值,这个值就是逻辑地址,是相对于当前进程数据段的地址。一个逻辑地址由两部份组成:段标识符和段内偏移量。

线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址生成了一个线性地址。如果启用了页式管理,那么线性地址可以再变换产生物理地址。若没有启用页式管理,那么线性地址直接就是物理地址。

虚拟地址:因为虚拟内存空间的概念与逻辑地址类似,因此虚拟地址和逻辑地址实际上是一样的,都与实际物理内存容量无关。

物理地址:存储器中的每一个字节单元都给以一个唯一的存储器地址,用来正确地存放或取得信息,这个存储器地址称为物理地址,又叫实际地址或绝对地址。

7.2 Intel逻辑地址到线性地址的变换-段式管理

8086处理器的寄存器是16位的,后来引入了段寄存器,可以访问更多的地址空间但不改变寄存器和指令的位宽。8086共设计了20位宽的地址总线,逻辑地址为段寄存器左移4位加上偏移地址得到20位地址。

内存被分为了不同的段,段寄存器有一个栈、一个代码寄存器和两个数据寄存器,在实模式下,逻辑地址、线性地址、实际的物理地址是完全一样的,保护模式下线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到

所有的段由段描述符描述,而多个段描述符能组成一个数组,我们称成功数组为段描述表。段描述符中的BASE字段对我们翻译线性地址至关重要的。

BASE字段表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。

为了得到BASE字段,我们利用索引号从GDT(全局段描述表)或LDT(局部段描述符表)中得到段描述符。选择GDT还是LDT取决于段选择符中的T1,若T1等于0则选择GDT,反之选择LDT。

这样我们就得到了BASE。最后通过BASE加上段偏移量就得到了线性地址

7.3 Hello的线性地址到物理地址的变换-页式管理

通过分页机制实现线性地址(书里的虚拟地址VA)到物理地址(PA)之间的转换。分页机制是指对虚拟地址内存空间进行分页。首先Linux系统有自己的虚拟内存系统,Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录,当hello运行时内核为hello进程维护一个段的任务结构(task_struct)。

虚拟页是指系统将每个段分为大小固定的块,linux下一页为4KB

物理页和虚拟页一样,他是物理内存的分割,MMU使用页表来实现二者之间的映射

7.4 TLB与四级页表支持下的VA到PA的变换

首先是TLB,TLB也就是翻译后备缓冲器是一个包含在MMU中的小缓存,其每一行都由一个PTE组成。TLB将一个n-p位VPN分为t位的组索引和n-t-p位的标记。

在访问时与cache几乎一致,先通过组索引找到所在组,在通过标记位判断是否是我们要访问的虚拟地址,如果命中则从中读取物理页号,并通与VPO组合成物理地址访问数据并将数据返回给CPU。如果不命中则必须从下一级TLB或者内存中寻找。

查询过程大概如下:

CPU 产生虚拟地址 VA,VA 传送给 MMU,MMU 使用前 36 位 VPN作为 TLBT(前 32 位)+TLBI(后 4 位)向 TLB 中匹配

如果命中,则得到 PPN(40bit)与 VPO(12bit)组合成 PA(52bit)。

如果 TLB 中没有命中,MMU 向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出 PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,

以此类推,最终在第四级页表中查询到 PPN,与 VPO 组合成 PA,并且向 TLB 中添加条目。

如果查询 PTE 的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。

7.5 三级Cache支持下的物理内存访问

Cache的访问并不复杂,对Cache的访问需要把一个物理地址分为标记、组索引、块偏移三个部分。首先我们通过组索引来找到我们的地址在Cache中所对应的组号,再通过标记和Cache的有效位来判断我们的内容是否在Cache中。若命中则通过块偏移读取我们要的数据,若不命中则从下一级Cache中寻找(下一级Cache不一定真的是Cache,比如对L3来说,它的下一级Cache就是主存)。

通过MMU将虚拟地址转化成物理地址后,计算机就通过提取中的组索引在L1中搜索组,再通过标记位匹配。如果匹配成功且有效位是1,则将块偏移指向的块中的内容交还给CPU,否则未命中,需要从下一级Cache中在重复上述操作。当我们找到内容后需要将内容写回我们的L1中,如果L1中没有空闲块,即有效位为0的块则需要牺牲一块内容,我们通常采用LRU算法来进行这一过程。对L2、L3的访问也是这样,因此就不再赘述。

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并给他分配一个唯一的PID。为了给这个进程创建虚拟内存,它创建了mm_struct、区域结构和页表的原样副本。他将两个进程中的每个页面都标记从只读,并将两个进程中的每一个区域结构都标记位私有的写时复制。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。

7.7 hello进程execve时的内存映射

加载并运行hello需要以下结构步骤:

删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。

映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。

映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。

设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。

7.8 缺页故障与缺页中断处理

缺页故障是一种常见的故障,当指令引用一个虚拟地址,在 MMU 中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。

如果程序执行过程中遇到了缺页故障,则内核调用缺页处理程序。处理程序会进行如下步骤:检查虚拟地址是否合法,如果不合法则触发一个段错误,程序终止。然后检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。在两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但不失通用性,假设堆是一个请求二进制零的区域,紧接在未初始化数据区域后开始,向上生长。对每个进程,内核维护一个全局变量brk指向堆顶。分配器将堆视为一组不同大小的块的集合来维护。

分配器的具体操作过程以及相应策略:

放置已分配块:当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。执行这种搜索的常见策略包括首次适配、下一次适配和最佳适配等。

分割空闲块:一旦分配器找到了匹配的空闲块,需要决定分配这个空闲块中多少空间。可以选择用整个块,但会造成额外的内部碎片;也可以选择将空闲块分割为两部分,第一部分变成已分配块,剩下的变成新的空闲块。

获取额外的堆内存:如果分配器不能为请求块找到空闲块,分配器通过调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插到空闲链表中,然后被请求的块放在这个新的空闲块中。

合并空闲块:分配器释放一个已分配块时,要合并相邻的空闲块。分配器决定何时执行合并,可以选择立即合并或者推迟合并。合并时需要合并当前块和前面以及后面的空闲块。

7.10本章小结

本章介绍了程序是如何组织储存器的。先从程序所使用的不同地址开始,分别介绍了逻辑地址、虚拟地址(线性地址)以及物理地址。并介绍了计算机是怎么一步步将地址从逻辑地址变化到虚拟地址再从虚拟地址变化到物理地址的。其中着重介绍了虚拟地址和物理地址之间的映射,以及进程是怎么映射到虚拟地址空间的。之后还介绍程序是怎么利用Cache来获取物理地址中所存放的数据的。最后简单介绍了虚拟地址中极为重要的概念——缺页异常,以及简单介绍了动态内存分配机制。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

一个Linux文件就是一个m个字节的序列:00101010011110…

所有的 IO 设备(例如网络、磁盘和终端)都被模型化为文件,所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单低级的应用接口,称为 Unix I/O。

8.2 简述Unix IO接口及其函数

Unix IO 接口,使得所有的输入和输出都能以一种统一且一致的方式来执行,它有以下几个功能:

打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备

Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。

改变当前文件的位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。

读写文件。一个读操作就是从文件复制n > 0个字符到内存,从当前文件位置k开始,然后k += n。对给定一个大小为m字节的文件,当k>=m时执行读操作会出发一个称为EOF的条件。

关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。

Unix I/O 函数:

进程通过调用open函数打开一个存在的文件或者创建一个新文件。

int open(char* filename,int flags,mode_t mode);

open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件;mode参数指定了新文件的访问权限位。

int close(fd),fd是需要关闭的文件的描述符(C中表现为指针),close 返回操作结果。

ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。

ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。

8.3 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;

}

首先 arg 获得第二个不定长参数,即输出的时候格式化串对应的值。

查看 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);

}

则知道 vsprintf 程序按照格式 fmt 结合参数 args 生成格式化之后的字符串,并返回字串的长度。

在 printf 中调用系统函数 write(buf,i)将长度为i的buf输出。write 函数如下:

write:

mov eax, _NR_write

mov ebx, [esp + 4]

mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

在 write 函数中,将栈中参数放入寄存器,ecx 是字符个数,ebx 存放第一个字符地址,int INT_VECTOR_SYS_CALLA 代表通过系统调用 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

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)” />}

可以看到getchar的底层实现是通过系统函数read实现的。getchar通过read函数从缓冲区中读入一行,并返回读入的第一个字符,若读入失败则返回EOF

异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中。

8.5本章小结

本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数。。在我看来Unix I/O是一个非常有趣且成功的抽象,因为它把一切输入输出都归结为对文件的操作,而且这一抽象是十分成功的,因为I/O的过程本质上就是一个对信息交换的过程,因此把所有与程序进行信息交换的主体,比如网络设备当作文件是完全没问题的。这种抽象不仅可以简化计算机的设计,还能更好的帮助我们理解学习系统级I/O。

结论

hello所经历的过程:

源程序:在文本编辑器或IDE中编写C语言代码,得到最初的hello.c源程序。

预处理:预处理器解析宏定义、文件包含、条件编译等,生成ASCII码的中间文件hello.i。

编译:编译器将C语言代码翻译成汇编指令,生成一个ASCII汇编语言文件hello.s。

汇编:汇编器将汇编指令翻译成机器语言,并生成重定位信息,生成可重定位目标文件hello.o。

链接:链接器进行符号解析、重定位、动态链接等创建一个可执行目标文件hello。此时,hello才真正地可以被执行。

fork创建进程:在shell中运行hello程序时,shell会调用fork函数创建子进程,供之后hello程序的运行。

execve加载程序:子进程中调用execve函数,加载hello程序,进入hello的程序入口点,hello终于要开始运行了。

运行阶段:内核负责调度进程,并对可能产生的异常及信号进行处理。MMU、TLB、多级页表、cache、DRAM内存、动态内存分配器相互协作,共同完成内存的管理。Unix I/O使得程序与文件进行交互。

终止:hello进程运行结束,shell负责回收终止的hello进程,内核删除为hello进程创建的所有数据结构。hello的一生到此结束,没有留下一丝痕迹。

对计算机系统的设计与实现的深切感悟:

hello从诞生到结束,经历了千辛万苦,在硬件、操作系统、软件的相互协作配合下,终于完美地完成了它的使命。这让我认识到,一个复杂的系统需要多方面的协作配合才能更好地实现功能。同时,计算机系统提供的一系列抽象使得实际应用与具体实现相互分离,可以很好地隐藏实现的复杂性,降低了程序员的负担,使得程序更加容易地编写、分析、运行。这让我认识到抽象是十分重要的,是计算机科学中最为重要的概念之一。

附件

hello.i hello的预处理结果

hello.s hello.i的汇编结果

hello.o hello.s翻译成的可重定位文件

hello 可执行文件

helloo.obj hello.o的反汇编结果

helloo.elfhello.o的ELF格式文件

hello.obj hello的反汇编结果

hello.elfhello的ELF格式文件

参考文献

[1] RANDALE.BRYANT, DAVIDR.O‘HALLARON. 深入理解计算机系统[M]. 机械工业出版社, 2011.

[2] https://www.cnblogs.com/clover-toeic/p/3851102.html

[3] https://www.runoob.com/linux/linux-comm-pstree.html

[4] https://www.runoob.com/cprogramming/c-function-vsprintf.html

[5] https://www.cnblogs.com/pianist/p/3315801.html

[6] https://www.cnblogs.com/jiqing9006/p/8268233.html

[7] https://www.cnblogs.com/xmphoenix/archive/2011/10/23/2221879.html

[8] https://www.cnblogs.com/fanzhidongyzby/p/3519838.html

[9] 深入解析Linux内核I/O剖析(open,write实现) – 笨拙的菜鸟 – 博客园

[10]https://blog.csdn.net/qq_52874833/article/details/118273279?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522165096340516782388058822%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fall.%2522%257D&request_id=165096340516782388058822&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~first_rank_ecpm_v1~rank_v31_ecpm-20-118273279.142^v9^pc_search_result_cache,157^v4^control&utm_term=%E5%93%88%E5%B7%A5%E5%A4%A7%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F&spm=1018.2226.3001.4187