关注

程序人生-Hello’s P2P

                                      

计算机系统大作业

                                                    题    目  程序人生-Hello’s P2P 

                                                    专    业   计算机与电子通信大类    

                                                    学 号    2023111044             

                                                    班 级     23L0501               

                                                    学    生     姬若涵              

                                                 指 导 教 师     刘宏伟                 

计算机科学与技术学院

2024年5月

摘  要

摘要:本论文旨在清晰阐述 C 语言程序从源代码到可执行文件的完整转变过程,以 hello.c 程序为例,深入浅出地讲解了计算机生成可执行文件时历经的预处理、编译、汇编、链接以及进程管理等关键阶段。hello 程序从 C 语言源代码起始,经预处理生成.i文件,再转化为易被机器理解的.s汇编文件,随后依次经历汇编、链接等步骤,最终蜕变为可执行文件,开启新的运行阶段。后续在操作系统协助下,hello 获得进程资源、虚拟内存及独立地址空间,凭借时间片和逻辑控制流畅运行,直至进程终结。

关键词:C语言程序;可执行文件;编译过程;进程管理;计算机系统    

目  录

第1章 概述................................................................................... - 4 -

1.1 Hello简介............................................................................ - 4 -

1.2 环境与工具........................................................................... - 4 -

1.3 中间结果............................................................................... - 5 -

1.4 本章小结............................................................................... - 5 -

第2章 预处理............................................................................... - 6 -

2.1 预处理的概念与作用........................................................... - 6 -

2.2在Ubuntu下预处理的命令................................................ - 6 -

2.3 Hello的预处理结果解析.................................................... - 7 -

2.4 本章小结............................................................................... - 8 -

第3章 编译................................................................................... - 9 -

3.1 编译的概念与作用............................................................... - 9 -

3.2 在Ubuntu下编译的命令.................................................... - 9 -

3.3 Hello的编译结果解析...................................................... - 10 -

3.4 本章小结............................................................................. - 15 -

第4章 汇编................................................................................. - 17 -

4.1 汇编的概念与作用............................................................. - 17 -

4.2 在Ubuntu下汇编的命令.................................................. - 17 -

4.3 可重定位目标elf格式...................................................... - 17 -

4.4 Hello.o的结果解析........................................................... - 21 -

4.5 本章小结............................................................................. - 23 -

第5章 链接................................................................................. - 24 -

5.1 链接的概念与作用............................................................. - 24 -

5.2 在Ubuntu下链接的命令.................................................. - 24 -

5.3 可执行目标文件hello的格式......................................... - 24 -

5.4 hello的虚拟地址空间....................................................... - 25 -

5.5 链接的重定位过程分析..................................................... - 30 -

5.6 hello的执行流程............................................................... - 34 -

5.7 Hello的动态链接分析...................................................... - 34 -

5.8 本章小结............................................................................. - 35 -

第6章 hello进程管理.......................................................... - 36 -

6.1 进程的概念与作用............................................................. - 36 -

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

6.3 Hello的fork进程创建过程............................................ - 36 -

6.4 Hello的execve过程........................................................ - 37 -

6.5 Hello的进程执行.............................................................. - 37 -

6.6 hello的异常与信号处理................................................... - 38 -

6.7本章小结.............................................................................. - 42 -

第7章 hello的存储管理...................................................... - 43 -

7.1 hello的存储器地址空间................................................... - 43 -

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

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

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

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

7.6 hello进程fork时的内存映射......................................... - 45 -

7.7 hello进程execve时的内存映射..................................... - 46 -

7.8 缺页故障与缺页中断处理................................................. - 46 -

7.9动态存储分配管理.............................................................. - 46 -

7.10本章小结............................................................................ - 47 -

第8章 hello的IO管理........................... 错误!未定义书签。

8.1 Linux的IO设备管理方法.................... 错误!未定义书签。

8.2 简述Unix IO接口及其函数................. 错误!未定义书签。

8.3 printf的实现分析.................................. 错误!未定义书签。

8.4 getchar的实现分析.............................. 错误!未定义书签。

8.5本章小结................................................. 错误!未定义书签。

结论............................................................................................... - 49 -

附件............................................................................................... - 51 -

参考文献....................................................................................... - 52 -

第1章 概述

1.1 Hello简介

1.1.1 P2P(从 Program Process)过程

hello 的故事始于程序猿将它敲入电脑保存为 hello.c(Program)。首先,它经历预处理阶段,处理宏定义、头文件包含等,接着进入编译阶段,将预处理后的文件编译成汇编代码,随后汇编器将汇编代码转化为机器语言的二进制文件即目标文件。链接器再将目标文件和系统库等进行链接,最终形成可执行文件。当运行程序时,在壳(Bash)里,操作系统(OS)的进程管理功能为其 fork 一个新进程,并通过 execve 执行该可执行文件,同时调用 mmap 等进行内存映射等操作,为其分配时间片,让它得以在硬件(CPU、RAM、IO 等)上运行,完成从程序到进程的转变。

1.1.2 020(从 Zero-0 Zero-0)过程

hello 的生命起始于零,即程序猿在编辑器中从零开始编写代码,敲入键盘将其存入电脑形成 hello.c。在经历上述 P2P 过程后,当 hello 进程执行完毕,OS 和 Bash 为其收尸,回收其所占用的资源,包括内存、时间片等,将其从硬件上移除,相当于又回到了零,整个过程体现了从无到有再到无的生命周期,即从 Zero-0(初始的零状态)到 Zero-0(最终的零状态),象征着它在计算机系统中诞生、运行、消亡的完整历程,涵盖了编辑器、编译器、汇编器、链接器、操作系统、硬件等计算机系统的各个环节。

1.2 环境与工具

1.2.1硬件环境

  1. 处理器:13th Gen Intel(R) Core(TM) i9-13900H,2600 Mhz,14 个内核,20 个逻辑处理器
  2. 已安装的物理内存(RAM):16.0 GB
  3. 系统类型:基于 x64 的电脑

1.2.2 软件环境

Microsoft Windows 11 版10.0.26100,VMware,Ubuntu

1.2.3 开发与调试工具

Visual Studio2022;vi/vim/gpedit+gcc;gdb;edb;readelf;objdump等。

1.3 中间结果

hello.c:原始hello程序的C语言代码

hello.i:预处理过后的hello代码

hello.s:由预处理代码生成的汇编代码

hello.o:二进制目标代码

hello:进行链接后的可执行程序

hello1_asm.txt:反汇编hello.o得到的反汇编文件

hello2_asm.txt:反汇编hello可执行文件得到的反汇编文件

1.4 本章小结

 本章通过简单介绍hello.c程序一生中的P2P过程和020过程,展示了一个源程序是如何经过预处理、编译、汇编、链接等阶段,生成各种各样的中间文件,最终成为一个可执行目标文件的。本章也介绍了本次实验所用到的硬件环境、软件环境以及开发工具等。

第2章 预处理

2.1 预处理的概念与作用

2.1.1 预处理的概念

预处理是 C 语言程序编译过程中的一个重要阶段。它在程序源代码被编译器编译之前进行,主要根据源代码中以字符 “#” 开头的预处理指令来进行相应的文本替换、文件包含、条件编译等操作。

2.1.2预处理的作用

  1. 宏定义 :通过使用 #define 指令可以定义宏,用于替换源代码中的标识符、表达式等。这有助于简化代码、提高代码的可读性和可维护性。例如,可以定义一个常量宏表示圆周率:#define PI 3.1415926,在代码中使用 PI 时就会被替换为对应的数值。
  2. 文件包含 :使用 #include 指令可以包含标准库文件或用户自定义的头文件。这样可以将多个文件中的代码整合在一起,便于代码的模块化管理,也便于代码的复用和共享。比如 #include 用于包含标准输入输出库的头文件,使得程序可以使用 printf、scanf 等函数。
  3. 条件编译 :通过使用 #ifdef、#ifndef、#if、#else、#elif 和 #endif 等指令,可以根据预定义的条件来选择性地编译代码。这在开发不同版本的程序(如调试版本和发布版本)或者针对不同平台进行开发时非常有用,能够实现对特定功能的开启或关闭,而无需修改大量代码。

2.2在Ubuntu下预处理的命令

图2.1 预处理指令

图2.2 生成文件

图2.3 hello.i内容

2.3 Hello的预处理结果解析

在对 hello.c 源文件进行预处理后,会得到一个包含大量头文件内容和预处理指令的文件。共有大致四个模块:

1. 预处理指令与文件包含模块

预处理过程展开了头文件内容,如 stdio.h 等。这些头文件提供了程序运行所需的函数声明和宏定义等。例如,printf、scanf 等函数的原型声明都来自于这些被包含的头文件。

2. 宏替换模块

预处理会将代码中的宏替换为相应的定义。在本例中,虽然 hello.c 源文件没有自定义宏,但系统头文件中包含的宏也会被展开。例如,一些用于条件编译的宏可能会被替换或展开。

3. 代码展开模块

源代码中的函数调用、变量声明等代码结构被保留,但头文件中的代码被直接插入到相应的位置。这意味着程序的逻辑和结构在预处理后仍然保持完整。

4. 程序主逻辑模块

这是程序的核心部分,包括主函数 main 的定义以及程序的输入输出逻辑。在这个模块中,程序根据命令行参数的个数判断是否正确,并在参数正确的情况下,循环输出信息并根据给定的秒数暂停。这个模块展示了程序的主要功能和用途。

2.4 本章小结

本章介绍了预处理的概念和作用,以及预处理的指令,随后分析了预处理的过程与结果。通过本章的学习,了解到C 语言预处理一般由预处理器(cpp)进行,主要完成四项工作:宏展开、文件包含复制、条件编译处理和删除注释及多余空白字符。

第3章 编译

3.1 编译的概念与作用

3.1.1 编译的概念

编译过程是将预处理后的 .i 文件翻译成汇编语言的 .s 文件的关键步骤。编译器深入分析源代码,执行语法分析和语义分析,将 C 语言的高级抽象转换为处理器可理解的低级指令。

3.1.2 编译的作用

这一过程不仅涉及将代码结构转换为汇编格式,还包括对数据类型进行处理,例如将 C 语言中的整数、浮点数、字符等数据类型映射到汇编语言中的相应表示形式。此外,编译器会优化代码,提升程序的运行效率和质量。

3.2 在Ubuntu下编译的命令

图3.1 编译指令

图3.2 生成文件

图3.3  hello.s内容

3.3 Hello的编译结果解析

3.3.1 记录文件信息

图3.4  hello.s内容

首先是记录文件相关信息的汇编代码。第一部分的汇编代码有一部分是以.作为开头的代码段。这些代码段是指导汇编器和连接器工作的伪指令。这段代码对我们来说没有什么意义,通常可以忽略这些代码,但对汇编器和连接器缺是十分重要的,为之后链接过程使用。伪代码的具体含义见下图。

图3.5 伪代码含义表

3.3.2 局部变量的处理

在 hello.s 文件中,变量的处理主要体现在栈帧的管理上。例如,在 main 函数的开头:

图3.6  hello.s内容

这里,%rbp 是基指针寄存器,用于标识当前栈帧的底部;%rsp 是栈指针寄存器,指向栈顶。subq $32, %rsp 为局部变量分配了 32 字节的栈空间。

Hello.c中含有局部变量i:

图3.7  hello.c内容

局部变量 i 被存储在栈帧中:

图3.8  hello.s内容

这行代码将整数 0 赋值给局部变量 i,-4(%rbp) 表示相对于基指针偏移 4 字节的位置,即变量 i 在栈帧中的位置。

3.3.3 常量和字符串的处理

在 hello.s 文件中,常量和字符串被放置在只读数据段(.rodata)中。例如:

图3.9  hello.s内容

这里.LC0 和 .LC1 是两个字符串常量。.LC0 包含了用法提示信息,而 .LC1 是 printf 函数使用的格式字符串。这些字符串在程序运行时被加载到内存的只读数据段中,通过指针访问。

3.3.4 立即数的处理

在汇编语言中,立即数用“$”后加数字表示。

图3.10  hello.s内容

3.3.5 整数类型的处理

整数类型的处理主要体现在对整数变量的赋值、比较和算术操作上。例如:

图3.11  hello.s内容

这行代码将整数 0 赋值给局部变量 i。

3.3.6 参数传递的处理

图3.12  hello.s内容

在main函数的开始部分,因为后面还会使用到%rbp数组,所以先将%rbp压栈保存起来。21行将栈指针减少32位,然后分别将%rdi和%rsi的值存入栈中。 由此我们知道,%rbp-20和%rbp-32的位置分别存了argv数组和argc的值。

3.3.7 数组的处理

图3.13  hello.s内容

对数组的操作,都是先找到数组的首地址,然后加上偏移量即可。例如在main中,调用了argv[1]和argv[2],在汇编代码中,每次将%rbp-32的的值即数组首地址传%rax,然后将%rax分别加上偏移量16和8,得到了argv[1]和argv[2],在分别存入对应的寄存器%rsi和%rdx作为第二个参数和第三个参数,之后调用printf函数时使用。

3.3.8 函数调用与返回的处理

函数调用涉及参数的传递和函数的调用。例如,在 printf 函数调用中:  

图3.14  hello.s内容

这里,%rcx、%rdx 和 %rsi 寄存器分别用于传递 printf 的三个字符串参数。.LC1(%rip) 是格式字符串的地址,通过 %rdi 寄存器传递给 printf 函数。call printf@PLT 指令调用 printf 函数。

3.3.9  for循环

图3.15  hello.s内容

movq -32(%rbp), %rax将存储在-32(%rbp)的变量的值加载到%rax寄存器中。addq $24, %rax将%rax寄存器中的值增加24,用于获取arg[1]的地址。movq (%rax), %rcx:argv[1]加载到%rcx寄存器中。movq -32(%rbp), %rax重复了第2步的操作,重新加载数组的指针到%rax寄存器中。addq $16, %rax增加了%rax中的值,通常用于访问数组中的另一个元素。movq (%rax), %rdx:将数组中的另一个元素加载到%rdx寄存器中。紧接着,程序重复了上述步骤,获取了argv[2]和argv[3]的值,分别存储在%rdx和%rsi寄存器中。

3.4 本章小结

本章详细解析了 hello.s 文件中编译器对 C 语言各种数据类型和操作的处理方式。通过对汇编代码的分析,我们了解到编译器如何将 C 语言的高级抽象转换为低级的汇编指令,包括对常量、变量、控制结构、函数调用等的处理。这些知识有助于我们更深入地理解 C 语言的底层实现,从而编写出更高效、更优化的代码。


                                            第4章 汇编

4.1 汇编的概念与作用

4.1.1  汇编的概念

汇编过程是将汇编语言源代码(.s 文件)转换成机器语言的二进制代码(.o 文件)的过程。这个过程由汇编器完成,它将汇编指令翻译成特定架构的机器指令,并生成可重定位的目标文件。这些目标文件包含了程序的代码和数据,但尚未链接到最终的可执行文件。

4.1.2 汇编的作用

汇编器处理汇编代码中的指令和伪操作,将它们转换为二进制表示形式,并为链接器提供必要的信息,以便后续的链接过程能够正确地组合多个目标文件,形成最终的可执行程序。

4.2 在Ubuntu下汇编的命令

图4.1 汇编指令

图4.2 生成文件

4.3 可重定位目标elf格式

4.3.1 ELF

由于hello.o文件是一个目标文件,因此无法直接使用vim打开。我们在终端输入readelf -h hello.o来解析elf文件头,结果如下。

图4.3 ELF头

ELF头以16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型(如可执行、可重定位或者共享的)、机器类型、节头部表的文件偏移以及节头部表中条目的大小和数量。

4.3.2 Section

节头部分记录了各节的名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐等信息。使用节头表中的字节偏移信息可以得到各节在文件中的起始位置,以及各节所占空间的大小,这样方便重定位。

图4.4 Section头

4.3.3 重定位节

图4.5 重定位节

在列出的信息中,偏移量表示需要被修改的引用的节偏移,符号值标识被修改引用应该指向的符号。类型告知连接器如何修改新的引用,加数是一个有符号常数,一些类型的重定位要使用它对被修改的引用的值做偏移调整。

4.3.4 符号表

图4.6 符号表

符号表存放了程序中定义和引用的函数和全局变量的信息。

4.4 Hello.o的结果解析

输入objdump -d -r hello.o 并回车可对hello.o文件进行反汇编,得到结果如下:

图4.7 反汇编结果

我们不难发现,反汇编得到的结果与hello.s中的汇编代码基本一致,但是还是存在一些出入:在每条指令的前面出现了一组组由16进制数字组成的代码,这就是机器代码。机器代码才是计算机真正可以识别的语言。

这些机器代码是二进制机器指令的集合,每一条机器代码都对应一条机器指令,每一条汇编语言都可以用机器二进制数据来表示。机器代码与汇编代码不同的地方有:

  1. 操作数

原本十进制的立即数都变成了二进制。这个很好理解,输出的文件是二进制的,对于objdump来说,直接将二进制转化为十六进制比较方便,也有利于程序员以字节为单位观察代码。

图4.8 反汇编代码

图4.9 汇编代码

  1. 分支跳转

在汇编语言中使用跳转指令只需要在后面加上标识符便可以跳转到标识符所在的位置,而机器语言经过翻译直接通过长度为一个字节的PC(Program Counter,程序计数器)相对地址进行跳转。

图4.10 反汇编代码

图4.11 汇编代码

4.5 本章小结

本章介绍了汇编的概念和作用,接着通过实操,对hello.s文件进行汇编,生成ELF可重定位目标文件hello.o,接着使用readelf工具,通过设置不同参数,查看了hello.o的ELF头、节头表、可重定位信息和符号表等,通过分析理解可重定位目标文件的内容。最后将其与hello.s比较,分析不同。

5章 链接

5.1 链接的概念与作用

5.1.1 链接的概念

链接(Linking)是程序开发过程中的一个关键步骤,它发生在编译和汇编之后。链接过程由链接器(Linker)负责完成,主要任务是将一个或多个目标文件(.o 文件)以及库文件中的代码和数据进行合并和整合,最终生成一个完整可执行的程序文件。

5.1.2 链接的作用

链接可以将程序调用的各种静态链接库和动态连接库整合到一起,完善重定位目录,使之成为一个可运行的程序。同时,链接的主要作用就是使得分离编译成为可能,从而不需要将一个大型的应用程序组织成一个巨大的源文件,而是可以将其分解成更小的、更好管理的模块,可以独立的修改和编译这些模块。

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.1 链接指令

图5.2 生成文件

5.3 可执行目标文件hello的格式

可执行目标文件的格式类似于可重定位目标文件(hello.o)的格式,但稍有不同。ELF头中字段e_entry给出执行程序时的第一条指令的地址,而在可重定位文件中,此字段为0。可执行目标文件多了一个程序头表,也称为段头表,是一个结构数组。可执行目标文件还多了一个.init节,用于定义_init函数,该函数用来执行可执行目标文件开始执行时的初始化工作。因为可执行目标文件不需要重定位,所以比可重定位目标文件少了两个.rel节。

5.3.1 ELF

图5.3 ELF头

hello的ELF头中Type处显示的是EXEC,表示时可执行目标文件,这与hello.o不同。hello中的节的数量为30个。

5.3.2 Section

图5.4  Section头

      Section表对hello中所有信息进行了声明,包括了大小、偏移量、起始地址以及数据对齐方式等信息。根据始地址和大小,我们就可以计算节头部表中的每个节所在的区域。

5.3.3 程序头表

图5.5 程序表头

查看hello的程序头表,首先显示这是一个可执行目标文件,共有12个表项,其中有4个可装入段(Type = LOAD),VirtAddr和PhysAddr分别是虚拟地址和物理地址,值相同。Align是对齐方式,这里4个可装入段都是4K字节对齐。以第一个可装入段为例,表示第0x00000~0x005f0字节,映射到虚拟地址0x400000开头的长度为0x5f0字节的区域,按照0x1000=4KB对齐,具有只读(Flags=R)权限,是只读代码段。

5.3.4 重定位节

图5.6 重定位节

5.3.4 符号表

图5.7 符号表

5.4 hello的虚拟地址空间

使用edb加载hello:

图5.8  edb加载hello

图5.9  edb加载hello

从5.3.3的程序头表看,LOAD可加载的程序段第一段的地址为0x400000,那么虚拟内存地址从由0x401000开始。

图5.10  hello的代码段

由5.3节我们又可以得知,.inerp段的起始地址为04002e0,用edb查看.inerp段的信息,如下图所示:

图5.11 .inerp段

同理,.init的起始地址为0x4004c0,在edb中查询地址可以得到如下图的结果:

图5.12 .init段

.text段的起始地址为0x400550,用edb查看.text段的信息,如下图所示:

图5.13 .text段

.rodata的起始地址为0x4006a0,在edb中查询地址可以得到如下图的结果:

图5.14 .rodata段

.eh_frame的起始地址为0x4006a0,在edb中查询地址可以得到如下图的结果:

图5.15 .eh_frame段

5.5 链接的重定位过程分析

输入命令objdump -d -r hello并回车,查看hello可执行文件的反汇编条目,结果如下:

图5.16  hello的反汇编结果

5.5.1 hello与hello.o的差异

hello的反汇编代码与hello.o的返汇编代码在结构和语法上是基本相同的,只不过hello的反汇编代码多了非常多的内容。

  1. 虚拟地址不同

hello.o的反汇编代码虚拟地址从0开始,而hello的反汇编代码虚拟地址从0x401000开始。这是因为hello.o在链接之前只能给出相对地址,而hello在链接

之后得到的是绝对地址。

图5.17  hello的反汇编代码

图5.18  hello.o

  1. 反汇编节数不同

hello.o只有.text节,里面只有main函数的反汇编代码。而hello在main函数之前加上了链接过程中各种在hello中被调用的函数、数据,增加了.init,.plt,.plt.sec等节的反汇编代码。

图5.19  hello的反汇编代码

  1. 跳转指令不同

hello.o中的跳转指令后加的主要是汇编代码块前的标号,而hello中的跳转指令后加的则是具体的地址,但相对地址没有发生变化。

图5.20  hello的反汇编代码

图5.21  hello.o

5.5.2 重定位的过程

以数据重定位为例,假设在 hello.o 中有一条指令是访问一个全局变量。在链接阶段,链接器会确定这个全局变量在可执行文件中的实际地址。然后,根据该地址和该指令在代码中的位置,计算出正确的偏移量。例如,如果全局变量的地址是 0x1000,而访问该变量的指令在代码中的地址是 0x200,那么链接器会将指令中的地址部分从原来的占位符地址(如可能是相对于段起始地址的偏移)修改为正确的偏移量(这里可能是 0x1000 - 0x200 或其他根据具体架构计算的偏移量)。这样,当程序运行时,指令就可以正确地访问该全局变量。

5.6 hello的执行流程

以下格式自行编排,编辑时删除

使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。

5.7 Hello的动态链接分析

动态链接器使用过程链接表PLT和全局偏移量表GOT实现函数的动态链接。通过hello节头表查询PLT和GOT的位置:    

图5.22  hello节头表

查阅节头可以得知,.got.plt起始位置为0x404000,在调用之后该处的信息发生了变化,如下图所示:

通过以上两张图的对比,可以得知,对于变量而言,我们利用代码段和数据段的相对位置不变的原则计算正确地址。对于库函数而言,需要plt、got合作,plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。plt就能跳转到正确的区域。

5.8 本章小结

本章节介绍了链接的相关过程,首先简要阐述了链接的概念和作用,给出了链接在Ubuntu系统下的指令。之后研究了可执行目标文件hello的ELF格式,并通过edb调试工具查看了虚拟地址空间和几个节的内容,之后依据重定位条目分析了重定位的过程,并借助edb调试工具,研究了程序中各个子程序的执行流程,最后则借助edb调试工具通过对虚拟内存的查取,分析研究了动态链接的过程。

6章 hello进程管理

6.1 进程的概念与作用

6.1.1 进程的概念

进程是操作系统资源分配和调度的基本单位。它是一个具有一定独立功能的程序关于某个数据集合的一次运行活动,由程序段、数据集合以及 PCB(进程控制块)组成。

6.1.2 进程的作用

在运行一个进程时,我们的这个程序看似是系统当中唯一一个运行的程序,进程的作用就是提供给程序两个关键的抽象:1.独立的逻辑控制流,即程序独占使用处理器的假象。2.私有的地址空间,即程序独占使用内存系统的假象。

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

6.2.1 Shell - bash 的作用

Shell - bash(Bourne Again Shell)是 Linux 系统中广泛使用的命令行解释器。它提供了用户与操作系统交互的接口,允许用户输入命令来执行各种任务,如文件操作、程序启动等。此外,bash 支持脚本编程,用户可以编写包含一系列命令的脚本文件,通过执行脚本来实现复杂的任务自动化,提高工作效率。

6.2.2 Shell - bash 的处理流程

当用户在终端输入命令后,bash 首先对命令进行解析,判断命令的类型(如内置命令或外部可执行文件)。对于内置命令(如 cd、echo 等),bash 直接在当前进程中执行;而对于外部命令(如 ls、gcc 等),bash 会创建一个新进程来运行该命令。在执行外部命令时,bash 会调用 fork() 创建子进程,并在子进程中调用 execve() 加载并执行相应的可执行文件。最终,bash 会等待命令执行完成,并获取其退出状态,以便进行后续操作。

6.3 Hello的fork进程创建过程

Shell调用fork创建子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,因此fork后子进程可以读写父进程中打开的任意文件。父进程和创建的子进程最大的区别在于其PID不同。

fork会被父进程调用一次,返回两次,父进程与创建的子进程并发执行。执行hello时,fork后的进程在前台执行,因此创建它的父进程shell暂时挂起等待hello进程执行完毕。

6.4 Hello的execve过程

execve函数加载并运行可执行目标文件Hello,且带参数列表argv和环境变量列表envp。该函数的作用就是在当前进程的上下文中加载并运行一个新的程序。只有当出现错误时,例如找不到filename,execve才会返回到调用程序,调用成功不会返回。与fork不同,fork一次调用两次返回,execve一次调用从不返回。

在execve加载了Hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下的原型:

int main(intargc , char **argv , char *envp);

结合虚拟内存和内存映射过程,可以更详细地说明exceve函数实际上是如何加载和执行程序Hello:

①删除已存在的用户区域(自父进程独立)。

②映射私有区:为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的。

③映射共享区:比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。

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

6.5 Hello的进程执行

6.5.1 进程上下文切换

上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这样的决策叫做调度,由内核中的调度器的代码处理。在这个抢占过程中需要用到上下文切换,上下文切换保存当前进程的上下文,恢复先前某个被抢占的上下文,并将控制传递给新恢复的进程。

6.5.2 时间分片

在现代计算机体系中,进程是轮流使用处理器的,每个进程都执行它的流的一部分,然后被抢占(暂时挂起),再轮到其它进程。一个逻辑流的执行在时间上与另一个流重叠被称为并发流,这两个流并发运行。

多个流并发执行的概念被称为并发。一个进程与其他进程轮流运行的概念称为多任务。一个进程执行其控制流一部分的每一个时间段叫做时间片,多任务也就被称作是时间分片。

6.5.3 进程时间片与调度

时间片分配 :操作系统采用调度算法(如多级反馈队列调度算法)为每个就绪进程分配一定的时间片。例如,在一个简单的轮转调度算法中,每个进程会依次获得一定时间(如几十毫秒)的 CPU 使用权。

进程切换 :当一个进程的时间片用完或者发生其他需要切换的事件(如等待 I/O 操作完成)时,操作系统会保存当前进程的上下文,选择另一个就绪进程来执行,并恢复其上下文。这个过程涉及到用户态与核心态的转换。

6.6 hello的异常与信号处理

6.6.1 异常的类别

异常可以分为四类:中断、陷阱、故障和终止,下图对这些类别的属性做了小结:

①中断:异步发生的。在执行hello程序的时候,由处理器外部的I/O设备的信号引起的。I/O设备通过像处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断。这个异常号标识了引起中断的设备。在当前指令完成执行后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。在处理程序返回前,将控制返回给下一条指令。结果就像没有发生过中断一样。

②陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep函数的时候会出现这个异常。

③故障:由错误引起,可能被故障处理程序修正。在执行hello时,可能出现缺页故障。

④终止:不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者 SRAM位被损坏时发生的奇偶错误。

6.6.2 产生的信号

  1. SIGINT:当用户按下Ctrl+C时发送,通常用于中断程序。
  2. SIGTSTP:当用户按下Ctrl+Z时发送,用于暂停程序。
  3. SIGTERM:请求程序终止的正常信号。

6.6.3 具体异常与运行结果

  1. 乱按

图6.1 输出结果

在键盘中乱打字不改变printf的输出,不影响程序的正常运行。

  1. CTRL+Z

图6.2 输出结果

Ctrl+Z的功能是向进程发送SIGSTP信号,进程接收到该信号之后会将该作业挂起,但不会回收。下图显示了,PID为21595的hello进程仍然在运行中。

  1. CTRL+C

图6.3 输出结果

在键盘中输入Ctrl+C,Ctrl-C命令内核向前台发送SIGINT信号,终止了前台作业。

  1. 一直按回车

图6.4 输出结果

在hello执行过程中不停按回车,不仅在printf输出时会显示出回车,在hello进程执行完毕后,我们可以看出回车的信息也同样发送到了shell中,使shell进行了若干次的刷新换行。

  1. Ctrl-z后可以运行ps  jobs  pstree  fg

图6.5 输出结果

图6.6 输出结果

  1. 首先输入Ctrl+Z,进程收到SIGTSTP信号,信号的动作是将hello挂起;
  2. 通过ps命令看到hello进程没有被回收,其进程号是2312;
  3. 用jobs命令看到job ID是1,状态是“已停止”;
  4. 接着输入pstree,以树状图形式显示所有进程;
  5. 输入fg,使停止的进程收到SIGCONT信号,重新在前台运行。

6.7本章小结

本章概述了hello进程大致的执行过程,阐述了进程、shell、fork、execve等相关概念,之后从逻辑控制流、时间分片、用户模式/内核模式、上下文切换等角度详细分析了进程的执行过程。并在运行时尝试了不同形式的命令和异常,每种信号都有不同处理机制,针对不同的shell命令,hello会产生不同响应。

7章 hello的存储管理

7.1 hello的存储器地址空间

①逻辑地址 (Logical Address):程序经过编译后出现在汇编代码中的地址,用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量;

②线性地址 (Liner Address):逻辑地址向物理地址转化过程中的一步,逻辑地址经过段机制后转化为线性地址,为描述符、偏移量的组合形式,分页机制中线性地址作为输入;

③虚拟地址 (Virtual Address):线性地址空间是一个非负整数的集合。逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式。在调试hello时,gdb中查看到的就是线性地址,或者虚拟地址。

④物理地址 (Physical Address):虚拟地址空间是0到N的所有整数的集合(N是正整数),是线性地址空间的有限子集。分页机制以虚拟地址为桥梁,将硬盘和物理内存联系起来。

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

一个程序可以由一个主程序、若干子程序、符号表、栈以及数据等若干段组成。每一段都有独立、完整的逻辑意义,每一段程序都可独立编制,且每一段的长度可以不同。段式存储管理支持用户的分段观点,具有逻辑上的清晰和完整性,它以段为单位进行存储空间的管理。

每个作业由若干个相对独立的段组成,每个段都有一个段名,为了实现简单,通常可用段号代替段名,段号从“0”开始,每一段的逻辑地址都从“0”开始编址,段内地址是连续的,而段与段之间的地址是不连续的。段式存储管理的逻辑地址由段号和段内地址两部分所组成。

段式管理是在可变分区存储管理方式的基础上发展而来的。在分段式存储管理方式中,以段为单位进行主存分配,每一个段在主存中占有一个连续空间,但各个段之间可以离散地存放在主存不同的区域中。为了使程序能正常运行,即能从主存中正确找出每个段所在的分区位置,系统为每个进程建立一张段映射表,简称“段表”。每个段在表中占有一个表项,记录该段在主存储器中的起始地址和长度。段表实现了从逻辑段到主存空间之间的映射。

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

 页式存储管理是把主存储器划分成大小相等的若干区域,每个区域称为一块,并对它们加以顺序编号,如0#块、1#块等。与此对应,用户程序的逻辑地址空间划分成大小相等的若干页,同样为它们加以顺序编号,从0开始,如第0页、第1页等。页的大小与块的大小相等。分页式存储管理的逻辑地址由两部分组成:页号和页内地址。

在分页式存储管理系统中,允许将作业的每一页离散地存储在主存的物理块中,但系统必须能够保证作业的正确运行,即能在主存中找到每个页面所对应的物理块。为此,系统为每个作业建立了一张页面映像表,简称页表。页表实现了从页号到主存块号的地址映像。作业中的所有页(0~n)依次地在页表中记录了相应页在主存中对应的物理块号。页表的长度由进程或作业拥有的页面数决定。

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

7.4.1 TLB 的作用与工作原理

翻译后备缓冲器(TLB)是一种高速缓存,用于缓存最近使用过的虚拟地址到物理地址的映射关系,以加快地址转换速度。

当需要进行地址转换时,首先在 TLB 中查找是否存在相应的映射。如果命中,则直接使用 TLB 中的物理地址进行内存访问;如果未命中,则通过页表查找转换,并将新的映射关系存入 TLB。

7.4.2四级页表支持下的VA到PA的变换

在 64 位系统中,为了支持更大的虚拟地址空间,通常采用四级页表结构。包括页目录指针表(PML4)、页目录表(PDPTE)、页表目录(PDE)和页表(PTE)。

线性地址被分为多个部分,分别用于索引 PML4、PDPTE、PDE 和 PTE。每一步查找相应的表项,最终得到物理地址。例如,线性地址的高位部分用于索引 PML4,中位部分用于索引 PDPTE,依此类推,直到找到 PTE 中的物理页面基址,加上页内偏移得到物理地址。

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

图7.1 三级Cache

Cache 是一种高速缓冲存储器,用于暂存主存中的部分内容,以缩短 CPU 访问内存的时间,提高系统性能。当 CPU 访问内存时,首先在 Cache 中查找所需数据或指令。如果命中,则直接从 Cache 中读取;如果未命中,则从主存中读取,并将相应数据加载到 Cache 中。

现代 CPU 通常采用三级 Cache 结构,包括 L1 Cache(一级缓存,通常集成在 CPU 核心内)、L2 Cache(二级缓存)和 L3 Cache(三级缓存)。L1 Cache 速度最快,容量较小;L3 Cache 容量较大,速度相对较慢,但被多个 CPU 核心共享。

在 hello 程序运行过程中,CPU 访问物理内存时,按照 L1 Cache→L2 Cache→L3 Cache→主存的顺序进行查找和访问。例如,当 CPU 需要读取一个变量的值时,会先检查 L1 Cache 中是否存在该变量对应的地址内容,如果不存在则逐级向下查找,直到从主存中读取数据,并在返回途中将数据缓存到相应的 Cache 中。

7.6 hello进程fork时的内存映射

当fork函数被调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本,并将两个进程中的每个界面都标记为只读,将两个进程中的每个区域结构都标记为私有的写时复制。

当fork在新进程中返回时,新进程现在的虚拟内存搞好和调用fork时存在的虚拟内存相同。这两个进程中的任意一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效替代当前程序。加载并运行hello需要以下四个步骤:

①删除当前进程虚拟地址中已存在的用户区域;

②映射私有区域,为新程序的代码、数据、bss和栈创建新的区域结构,所有这些新的区域都是私有的、写时复制的;

③映射共享区域,将hello与libc.so动态链接,然后再映射到虚拟地址空间中的共享区域;

④设置当前进程上下文程序计数器(PC),使之指向代码区域的入口点。

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

①处理器生成一个虚拟地址,并把它传送给MMU;

②MMU生成PTE地址,并从高速缓存/主存请求得到它;

③高速缓存/主存向MMU返回PTE;

④若PTE中的有效位是零,则MMU触发一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序;

⑤缺页异常处理程序确定物理内存中的牺牲页,如果这个页面已经被修改,则把它换出到磁盘;

⑥缺页处理程序页面调入新的页面(内核从磁盘复制所需的虚拟页面到内存中),更新内存中的PTE;

⑦缺页处理程序返回到原来的进程中,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU,因为虚拟页面已经现在在物理内存中了,所以就会命中。

7.9动态存储分配管理

7.9.1 动态内存分配器

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆是一个请求二进制零的区域,它紧接在未初始化数据区域(.bss)后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个brk指针,指向堆的顶部。

分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显示地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

分配器有两种基本风格: ①显式分配器:要求应用显式地释放任何分配的块,例如C标准库提供的malloc程序包。 ②隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,那么就是放这个块,也被称为垃圾收集器。

7.9.2隐式空闲链表

在隐式空闲链表工作时,如果分配块比空闲块小,可以把空闲块分为两部分,一部分用来承装分配块,这样可以减少空闲部分无法使用而造成的浪费。隐式链表采用边界标记的方法进行双向合并。脚部与头部是相同的,均为 4 个字节,用来存储块的大小,以及表明这个块是已分配还是空闲块。同时定位头部和尾部,是为了能够以常数时间来进行块的合并。无论是与下一块还是与上一块合并,都可以通过他们的头部或尾部得知块大小,从而定位整个块,避免了从头遍历链表。但与此同时也显著的增加了额外的内存开销。他会根据每一个内存块的脚部边界标记来选择合并方式。

7.9.3 显式空闲链表

显式空闲链表只记录空闲块,而不是来记录所有块。它的思路是维护多个空闲链表,每个链表中的块有大致相等的大小,分配器维护着一个空闲链表数组,每个大小类一个空闲链表,当需要分配块时只需要在对应的空闲链表中搜索。

7.10本章小结

本章深入探讨了 hello 程序在存储管理方面的多个关键环节。从存储器地址空间的概念入手,详细解析了逻辑地址、线性地址、虚拟地址和物理地址之间的关系及转换过程。通过分析 Intel 架构下的段式管理和页式管理机制,阐述了 hello 程序在运行时如何实现内存访问。同时,探讨了 TLB、多级页表和 Cache 等硬件特性对内存访问性能的提升作用。在进程层面,分析了 fork 和 execve 过程中的内存映射和管理策略,以及缺页故障处理机制。最后,介绍了动态存储分配管理的基本方法和策略。

结论

一、对hello所经历历程的总结

  1. 预处理(cpp)。将hello.c进行预处理,将文件调用的所有外部库文件合并展开,生成一个经过修改的hello.i文件。
  2. 编译(ccl)。将hello.i文件翻译成为一个包含汇编语言的文件hello.s。
  3. 汇编(as)。将hello.s翻译成为一个可重定位目标文件hello.o。
  4. 链接(ld)。将hello.o文件和可重定位目标文件和动态链接库链接起来,生成一个可执行目标文件hello。
  5. 运行。在shel1中输入./hello 2022112040 qdx 15845895165 4 并回车。
  6. 创建进程。终端判断输入的指令不是shell内置指令,于是调用fork函数创建一个新的子进程。
  7. 加载程序。shell调用execve函数,启动加载器,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数。
  8. 执行指令:CPU为进程分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。
  9. 访问内存:MU将程序中使用的虚拟内存地址通过页表映射成物理地址。
  10. 信号管理:当程序在运行的时候我们输入Ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
  11. 终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构。
  • 感悟

学习计算机系统课程确实是一段充满挑战但极具收获的旅程。这门课程涵盖了从硬件架构到软件系统的多个层面,知识体系庞大且复杂。一开始面对众多抽象概念,常常感到难以捉摸,甚至有些吃力。

然而,随着课程的深入,完成了一次次实验和大作业,那些晦涩难懂的知识点逐渐变得清晰起来,我对计算机系统内部的运作机制有了更加直观的认识。这种实践过程不仅加深了对课堂知识的理解,还培养了解决实际问题的能力。

这门课程让我明白,计算机专业绝不仅仅是写写代码这么简单,背后还有好多底层的硬件和软件在一块儿协同工作。这门课程为我后续学习操作系统、计算机网络等专业课打下了扎实的基础,也让我对计算机科学产生了更浓厚的兴趣。

附件

hello.c:原始hello程序的C语言代码

hello.i:预处理过后的hello代码

hello.s:由预处理代码生成的汇编代码

hello.o:二进制目标代码

hello:进行链接后的可执行程序

hello1_asm.txt:反汇编hello.o得到的反汇编文件

hello2_asm.txt:反汇编hello可执行文件得到的反汇编文件

参考文献

  1.  Randal E.Bryant David R.O'Hallaron.深入理解计算机系统(第三版).机械工业出版社,2016.
  2.  https://www.cnblogs.com/pianist/p/3315801.html
  3. https://www.csd.cs.cmu.edu

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2302_81586836/article/details/148027575

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--