程序人生-Hello’s P2P

摘  要

本文以一个简单的"hello.c"程序为起点,深入探讨了程序在Linux操作系统下的完整生命周期。这个生命周期包括预处理、编译、汇编、链接等阶段,以及进程管理、存储管理和I/O管理的环节。通过描述,读者得以清晰地观察"hello.c"从键盘输入、保存到磁盘,再到程序运行结束,最终变为僵尸进程的全过程。这不仅展现了程序的生命历程,也使我们更深入地理解了计算机程序的运行机制。

关键词:预处理,编译,汇编,链接,进程管理,存储管理,IO管理   


第1章 概述

1.1 Hello简介

Hello语言代码实现了一个简单的程序。用户通过命令行输入学号、姓名和等待时间,Hello则在循环中输出“Hello 学号 姓名 秒数”的消息,每次输出后暂停指定的时间

P2P:程序的生成并不是一蹴而就的简单过程,而是经历了多个阶段的处理。包括预处理、编译、汇编和链接。预处理阶段清理和准备源代码,编译将源代码转换为汇编语言,汇编将汇编语言转换为机器可识别的指令,链接则将多个目标文件连接为一个可执行文件hello。具体过程如流程图:

下面是相关流程图:

图1:hello程序流程图

O2O:在程序运行时,通过打开Shell等待输入指令。当我们键入 ./hello 时,Shell会创建一个新的进程来执行名为 "hello" 的程序。操作系统通过 fork 创建子进程,随后通过 execve 装载程序,并进行一系列操作,包括内存分配、访存等,以便程序在系统中运行。当程序执行结束后,控制权返回父进程或祖先进程,这些进程负责回收子进程的资源,确保系统资源得到释放。这个回收过程是操作系统对进程生命周期的管理的一部分,确保系统的稳定性和资源的有效利用。

1.2 环境与工具

硬件环境:X64 CPU;2.9GHz;16G RAM;512G SSD

软件环境:Windows10 64位;VMware17.0;Ubuntu 22.04.2 LTS 64位

开发与调试工具:Visual Studio 2022,gcc,vim,edb,objdump,readelf,Codeblocks

1.3 中间结果

表1:相关文件

1.4 本章小结

本章简述了Hello程序的一生,概括了从P2P到020的整个过程,还介绍了相关软硬件环境和开发调试工具,最后列出了相关中间文件以供参考。


第2章 预处理

2.1 预处理的概念与作用

概念:预处理是程序编译过程中的第一个阶段,它在实际编译之前对源代码进行一些文本替换和处理,为编译器提供经过处理的源代码。在C语言中,预处理器负责执行预处理操作。

作用:对于头文件:

图2:头文件

预处理器会将这些头文件的内容插入到程序文本中,以便在编译时能够识别和使用程序中的 printf、sleep 等函数。实际上,这些头文件中包含了一些宏定义和条件编译,但这些细节Hello中不明显。\

   预处理指令:


表2:相关处理指令

2.2在Ubuntu下预处理的命令

在Ubuntu下,预处理的命令为:gcc -E hello.c -o hello.i

-E参数指对文件进行预处理操作

-o选项用来指定输出文件为hello.i

最后生成hello.i文件

图3:预处理过程

2.3 Hello的预处理结果解析

打开hello.i文件,发现其内容从原来的20多行变成3000多行,是因为预处理使得代码会包含一些在源代码中看不到的内容,包括:

头文件内容:所有被包含的头文件<stdio.h>,<unistd.h>,<stdlib.h>的内容会被插入到预处理后的输出文件

宏展开:如果在代码中使用了宏定义(本代码未涉及),预处理器会将所有宏展开,将宏的定义替换为其对应的内容

条件编译:任何由条件预处理指令控制代码块会根据条件的真假进行处理(本代码未涉及),生成的文件会根据条件编译的结果有所不同

注释的处理:预处理器可能会处理注释并在预处理输出中进行适当的处理

2.4 本章小结

本章介绍了C程序中的预处理阶段,强调了预处理的概念和其在编译运行过程中的作用。预处理作为编译的首要步骤,生成的.i文件展示了原始源文件中的代码,还包括了通过头文件包含等操作引入的额外内容。通过查看.i文件,读者能更直观地感受到预处理前后源文件的变化,理解了预处理器对代码的处理过程,为后续编译和运行阶段奠定了基础。



第3章 编译

3.1 编译的概念与作用

概念:编译阶段接收预处理后的代码并进行语法和语义检查,将代码转换为中间代码。生成的中间代码是一种抽象的、与硬件无关的表示。

作用:产生汇编语言文件,并交给机器,为后续的目标代码生成和最终可执行文件的链接提供了基础。编译阶段任务是确保代码的正确性、可维护性,并通过优化提高程序的性能。

        

3.2 在Ubuntu下编译的命令

在Ubuntu下,编译的命令为:gcc -S hello.i -o hello.s

-S参数指对文件进行编译操作

-o选项用来指定输出文件为hello.

最后生成hello.s文件

图4:编译过程

3.3 Hello的编译结果解析

3.3.1 文件信息

图5:文件信息

  1. .file "hello.c":标识源文件的名称
  2. .text:开始文本段,包含程序的实际代码
  3. .section .rodata:只读数据段,用于存储只读的常量数据
  4. .LC0、LC1:用于存储字符串的标签
  5. .string代表字符串
  6. .align 指出对齐方式为8字节对齐
  7. .globl main 和 .type main, @function:将 main 函数声明为全局函数

3.3.2 局部变量

    

图6:局部变量

这里相当于源代码语句:int i

对于32位系统,局部变量存储在栈中,当进入函数main的时候,会根据局部变量的需求,在栈上申请一段空间供局部变量使用。当局部变量的生命周期结束后,会在栈上释放。这里,将立即数0村村到%ebp-4的位置,相当于将将一部分空间用于存储随机变量。

3.3.3 字符串常量

图7:字符串常量

字符串常量在hello.s最开头.rodata(只读)的LC0与LC1处被储存,且标记为只读数据,防止字符串数据被修改。这里以.LC0举例,.LC0(%rip):是一个内存操作数,表示位于当前指令(指令指针寄存器 %rip 指向的位置)的偏移地址为 .LC0 的数据。这条指令的目的是将字符串 .LC0 的地址加载到 %rdi 寄存器中,准备将该地址传递给后续的函数调用。

3.3.4 赋值操作

             

图8:赋值

赋值操作通过数据传送指令来进行,主要是MOV类指令,包括movb,movw,movl,movq 这里表示将立即数0x0给%eax

3.3.5 算术操作

图9:算术操作

算术操作主要涉及到寄存器之间的数据传递和基本的操作,如:
加法指令(ADD):add dest, src:将 src 寄存器或内存中的值加到 dest 寄存器中

减法指令(SUB):sub dest, src:从 dest 寄存器中减去 src 寄存器或内存中的值

乘法指令(IMUL):imul dest, src:将 dest 寄存器与 src 寄存器或内存中的值相乘

除法指令(IDIV):idiv divisor:将累加器寄存器(通常是 %rax)中的值除以 divisor 寄存器或内存中的值。商存储在累加器中,余数存储在 %rdx 中

递增指令(INC):inc operand:将操作数的值加1。通常用于递增计数器

递减指令(DEC):dec operand:将操作数的值减1。通常用于递减计数器

3.3.6 关系操作

图9:关系操作

包含两种相关指令:

1.test 指令:

test 指令执行按位逻辑与(AND)操作,但不保存结果,只更新标志寄存器的状态。通常用于检查寄存器或内存中的位模式,而不需要保存结果,只关心设置零标志(ZF)和符号标志(SF)。

2.cmp 指令:

cmp 指令用于比较两个操作数,但不保存结果,只更新标志寄存器的状态。它是通过执行减法操作,但不保存减法结果,只更新标志寄存器。

    这里比较%rbp-4的地址和立即数7 若满足jle=1则发生跳转。

3.3.7 数组访问

图10:数组访问

    数组的访问方式涉及到偏移量,与基址寄存器相结合,基址寄存器包含数组的起始地址。通过首地址+偏移量的方式实现访问数组。以上述为例,%rbp-32存储的是数组头元素的地址,将其存入%rax中,通过add语句+16,实现访问argv[2],将其对应地址存入%rdx中。

3.3.8 条件分支

图11:条件分支

条件分支的实现离不开关系操作与跳转,通过对判断条件的比较设置标志位,在通过检查标志位决定是否跳转到另一部分语句。这里相当于源代码中if(argc!=4)的语句,相等则跳转到一个语句继续执行。

3.3.9 循环

图12:循环

循环的调用同样需要关系操作和跳转,这里如上图所示,L3前两行执行一个判断,这里相当于源代码的i<8,若符合判断条件,则跳转到L4,这里L4相当于一个循环体,当再次运行到L3时,两者重新比较。这里和dowhile语句不同的是,for循环和while需要先一步执行判断语句。

3.4 本章小结

在汇编指令层面,代码使用了寄存器和栈操作来存储局部变量和处理函数调用。条件跳转指令(如je和jle)用于控制循环的执行。函数调用通过call指令实现,而返回则通过ret指令完成。总体而言,这段代码通过分支判断、循环和字符串处理,展示了对函数调用、栈操作以及条件分支的汇编实现。



第4章 汇编

4.1 汇编的概念与作用

概念:在汇编过程中,汇编器将每条汇编指令翻译成对应的机器指令,并生成一个目标文件(.o文件),其中包含机器指令的二进制表示。

作用:将汇编代码转换为机器可执行的二进制代码,计算机只有通过机器指令才能实现各运算过程。

4.2 在Ubuntu下汇编的命令

 在Ubuntu下,编译的命令为:gcc -c hello.s -o hello.o

-c参数指对文件进行汇编操作但不链接

-o选项用来指定输出文件为hello.o

最后生成hello.o文件

图13:汇编生成.o文件

4.3 可重定位目标elf格式

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

图14:典型的ELF可重定位目标文件格式

ELF头以一个16个字节的序列开始,以节头部表结尾,夹在中间的都是节,一个典型的ELF可重定位目标文件包含下面几个节:

1 .text:已编译的机器代码

2 .rodata:只读数据,比如字符串常量

3 .data:已初始化的全局和静态C变量

4 .bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量,5 .symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息6 .rel.text:.text节的位置列表

7 .rel.data:被模块引用或定义的所有全局变量的重定位信息

8 .debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量以及原始的C源文件,在编译时以-g选项调用编译器驱动程序,才会得到这张表

9 .line:原始C源程序中的行号和.text节中机器指令之间的映射

10 .strtab:字符串表,包含.symtab和.debug节中的符号表

使用指令:readelf -h hello.o查看ELF头

图15:hello.o ELF头


相关信息

Magic (魔数):

7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00,这是ELF文件的标识,通常以"7F

ELF"开头,用于识别文件格式。

类别:

 ELF64表示这是一个64位的ELF文件

数据:

2 补码,小端序 (little endian),表示数据的存储方式,这里是小端序

Version:

1 (current),ELF格式的版本,当前版本是1

OS/ABI:

UNIX - System V,表示运行这个程序的操作系统和ABI(Application Binary

Interface)版本

ABI 版本:0,表示ABI的版本

类型:

REL (可重定位文件),表示这是一个可重定位文件,即一个目标文件

使用指令:readelf -S hello.o查看节头

图16:hello.o节头

使用指令:readelf -s hello.o查看符号表

图17:hello.o符号表

使用指令:readelf -r hello.o查看可重定位段信息

图18:hello.o可重定段信息

具体分析:

         1. 重定位节 '.rela.text':

PC相对32位或PLT相对32位的重定位项,对应于.rodata中的符号如exit、printf、atoi、sleep和getchar

2. 重定位节 '.rela.eh_frame':

偏移量: 000000000020信息: 000200000002

类型: R_X86_64_PC32符号值: 0000000000000000

符号名称 + 加数: .text + 0

一个PC相对32位重定位项,它指示在偏移量0x20处的位置,通过从.text的地址减去0来解决地址。

4.4 Hello.o的结果解析

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

使用指令:objdump -d -r hello.o查看hello.o的反汇编

图19:反汇编与hello.s对比

通过对比反汇编和原始的hello.s文件,读者可以发现,汇编指令在反汇编结果中得到了近乎一致的呈现。然而,反汇编结果不仅包含了汇编指令,还额外展示了机器代码,这些代码在左侧以16进制的形式表示。机器指令由操作码和操作数组成,它们与汇编指令一一对应。最左侧显示的则是相对地址,为理解程序的执行流程提供了关键线索。这样的逻辑结构使得反汇编结果不仅直观易懂,还为读者深入研究程序的运行机制提供了宝贵的信息。

4.5 本章小结

以下格式自行编排,编辑时删除)在本章我们理解了汇编的概念和在文件转换时的作用。通过实际操作,对hello.s文件进行了汇编,生成了ELF可重定位目标文件hello.o。借助了readelf工具,并查看其ELF头、节头表、可重定位信息和符号表等关键部分。通过详细分析这些信息,读者不仅可以理解可重定位目标文件的结构和内容,还进一步揭示了机器语言与汇编语言之间的一一对应关系。最后,将生成的hello.o与原始的hello.s文件进行了比较。通过对比两者的不同之处,读者能够更清晰地看到机器指令与汇编指令之间的对应关系,从而深化对两者关系的理解。


第5章 链接

5.1 链接的概念与作用

概念:链接是将一个或多个目标文件组合成一个可执行文件的过程。在这个过程中,链接器负责解析符号引用、地址重定位、合并代码和数据段等任务,最终生成一个完整的可执行文件。

作用:解决处理多个源文件之间的符号引用关系,调整相对地址,合并代码和数据段,最终产生能够在操作系统上独立运行的完整程序。确保程序的各个部分正确协同工作,是将散乱的代码和数据整合成一个可执行单元的关键步骤。

5.2 在Ubuntu下链接的命令

在Ubuntu下,编译的命令为:

ld -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 /usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello

-dynamic-linker /lib64/ld-linux-x86-64.so.2:

指定动态链接器的路径为 /lib64/ld-linux-x86-64.so.2,它是用于在运行时加载共享库的系统动态链接器

/usr/lib/x86_64-linux-gnu/crt1.o:

包含C运行时环境的启动代码

/usr/lib/x86_64-linux-gnu/crti.o:

包含C运行时环境的初始化代码

/usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o:

包含了GCC库的启动代码

-lc:

指定链接时需要使用C库

/usr/lib/gcc/x86_64-linux-gnu/9/crtend.o:

包含了GCC库的结束代码

/usr/lib/x86_64-linux-gnu/crtn.o:

包含了C运行时环境的结束代码

-z relro:

启用 RELRO保护,将某些节的重定位表标记为只读

-o hello:

指定输出文件的名称为 hello,也就是生成的可执行文件的名称

图20:链接过程

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

查看ELF头:

图21:helloELF头

查看节表头:

图22:hello节表头

查看程序表头:

图23:hello程序表头

5.4 hello的虚拟地址空间

图24:edb查看

图25:edb查看

从图中可以看出,hello虚拟空间从0x401000开始载入,查阅可知,是.init的地址,通过对比程序头部表可以发现0x401000正是程序载入地址,且查阅节头部表发现0x401000确实是.init的地址。

图26:edb对比

同理,.text节的起始地址为0x4010f0,这也是ELF头中显示的程序的入口点地址,通过与edb上hello的虚拟地址空间对照可以发现0x4010f0处有一个_start函数,这是程序的入口点。

图27:.Rodata

最后,从节头部表可以发现.rodata节的起始地址为0x402000,这不属于程序的代码部分,而是一个只读数据,按前文编译中介绍的,该只读数据里存有两个printf语句中用到的格式串。用edb查看该地址发现确实存有这两个字符串。

5.5 链接的重定位过程分析

使用指令:objdump -d -r hello对hello进行反汇编

图27:反汇编结果

我们可以发现,地址已经变成了虚拟空间的地址,而不是hello.o中的偏移量,对于跳转指令和call指令,在hello.o中为绝对地址,而在hello中是重定位之后的虚拟地址。

图28:call指令地址

这里以call指令为例,执行call指令后的下一条指令地址为0x4011fb,与puts指令地址0x401090之差转化为补码为0xfffffe95,正好是call指令的地址(小端表示法)。

5.6 hello的执行流程

表3:执行流程

5.7 Hello的动态链接分析

分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。

动态的链接器在正常工作时链接器采取延迟绑定的链接器策略,将过程地址的绑定推迟到第一次调用该过程时。

这里先了解一下got段,它用于存储全局偏移表。这个表包含了程序中所有对全局变量或函数的引用,在运行时,这些引用会被解析为实际的内存地址。

_dl_init 函数: _dl_init 是动态链接器在程序启动时执行的一个函数。负责动态链接和初始化进程中的动态共享对象。
 这里以.got为例,首先找到.got的地址为0x403ff0,接下来edb查看

图29:edb初始化

可以看出其已经被初始化

5.8 本章小结

本章首先阐述了链接的概念和作用,探讨了可执行目标文件的内部结构。在此基础上详尽地解析了重定位过程,这一关键技术对于将相对地址转换为实际内存地址至关重要。以可执行目标文件“hello”为例,对其各个组成部分,如代码段、数据段和符号表,进行了分析。此外还探讨了虚拟地址空间的概念,以及程序的执行流程。通过这些分析,使读者能够更好地掌握链接、可执行目标文件、重定位过程以及相关概念。



6hello进程管理

6.1 进程的概念与作用

概念:进程是计算机系统中正在执行的程序的实例。每个进程有独立的内存空间、程序计数器、寄存器集合和其他系统资源。

作用:实现并发执行,提高系统效率和响应速度,防止进程间相互干扰。进行任务调度,通过操作系统的调度算法决定何时执行哪个进程。同时具备容错性,进程的独立性确保一个进程的崩溃不会影响其他进程。

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

作用:命令解释器:Bash 允许用户通过命令行输入命令,然后将命令翻译为

操作系统能够理解和执行的指令

脚本编程:Bash 是一种脚本语言的解释器,允许用户编写脚本文件,包含一系列 Shell 命令,以便自动执行一系列任务

环境配置:Bash 负责管理用户的运行环境,包括环境变量、别名、路径等,确保用户能够方便地使用系统和应用程序

管道和重定向:Bash 支持将一个命令的输出作为另一个命令的输入,以及通过重定向符号来控制输入和输出的流向

处理流程:用户输入命令:用户在命令行界面输入命令或脚本

命令解析:Bash 解析用户输入的命令,识别命令和参数,执行相应的语法分析

环境变量和别名扩展:Bash 根据用户配置的环境变量和别名对命令进行扩展和替换

命令执行:Bash 执行用户输入的命令或脚本,调用相应的系统调用来执行指定的操作。

管道和重定向:如果命令包含了管道或重定向符号,Bash 将相应地设置输入和输出,将命令的输出传递给其他命令或将输出重定向到文件

错误处理:Bash 在执行命令时会进行错误检查,并提供错误信息

返回结果:执行完命令后,Bash 返回相应的结果,以便用户和其他程序能够根据执行结果采取相应的措施

6.3 Hello的fork进程创建过程

Hello程序开始执行时,首先创建一个初始进程。随后,程序调用fork函数(pid_t fork(void);)来创建一个新的子进程。子进程复制包括代码、数据和文件描述符。在 fork 函数之后,程序会有两个执行流,一个在父进程中,另一个在子进程中。这两个进程几乎拥有相同的代码和变量。在 if (argc != 4) 的条件语句中,判断当前是父进程还是子进程。根据 fork 函数的返回值(在父进程中返回子进程的 ID,在子进程中返回0),可以判断执行的路径。以下是具体流程图:

图30:fork创建及后续流程

6.4 Hello的execve过程

execve函数用于在当前进程中加载并运行一个新程序。它会替换当前进程的映像为新的可执行目标文件,而不是创建一个新的进程。这意味着当execve成功执行,它将不会返回到原始程序。但是,如果在加载和运行新程序时发生错误,例如找不到指定的文件,execve函数会失败并返回到原始程序。

与fork函数不同,execve函数只进行一次调用,然后要么继续执行新程序,要么返回到原始程序。而fork函数会创建新的子进程,并在父进程和子进程中都有返回。

 execve函数声明为:int execve(char *filename,char *argv[],char *envp[]),包含以下几个参数:

1.filename: 字符串,表示要执行的新程序的文件路径

2.argv[]: 这是一个字符串数组,表示新程序的命令行参数。数组的第一个元素 argv[0] 通常是程序的名称,其后的元素包含程序执行时的参数

3.envp[]: 这是一个字符串数组,表示新程序执行时使用的环境变量。数组的每个元素都是形如 "NAME=VALUE" 的字符串,表示一个环境变量

6.5 Hello的进程执行

进程上下文信息:

包括进程的寄存器、内存映像、文件描述符等信息。在Hello中,主要的上下文信息是通过函数参数 argc 和 argv 传递的。

进程时间片:

进程调度是操作系统的任务之一,它负责在多个进程之间切换执行,以便给用户提供交替执行的感觉。每个进程被分配一个时间片,表示它能够运行的时间。在时间片用完或进程主动让出CPU时,操作系统会进行进程切换。

进程调度的过程:

当程序运行时,操作系统为其分配一个进程控制块,其中包含有关进程的信息,例如寄存器状态、程序计数器等。

操作系统根据调度选择一个就绪队列中的进程来运行,将CPU的控制权交给该进程。在Hello中,程序执行循环,打印问候信息并通过 sleep 函数暂停一定的时间。在 sleep 期间,进程被置于阻塞状态,不占用CPU。当 sleep 结束后,

进程重新进入就绪状态。

用户态与核心态转换:

当程序在用户态执行时,它只能执行受限的指令,不能直接访问系统资源。当需要执行特权指令时,需要进行用户态到核心态的转换。(例如本程序的sleep函数)

图31:进程上下文切换示意图

6.6 hello的异常与信号处理

hello执行中可能出现的异常:

1:中断:用户在程序运行时按下Ctrl+C,产生中断信号,按下Ctrl+C会终止进程。可以通过注册信号处理函数来捕获并执行自定义操作。

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

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

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

接下来将展示一些常见命令:

  1. 输入回车:

图32:输入回车

  1. ctrl+c

图32:输入ctrl+c

  1. ctrl+z

图33:输入ctrl+z

  1. Ps 监视后台程序

图34:输入ps

  1. 输入jobs显示当前暂停的进程

图35:输入jobs

  1. 输入pstree 以树状图形式显示所有进程

图36:输入pstree

  1. 输入fg 使停止的进程收到 SIGCONT信号 重新在前台运行

图37:输入fg

  1. 输入kill -9表示给进程发送9号信号 即SIGKILL 杀死进程

图37:输入kill

6.7本章小结

这一章探讨了程序从可执行文件到进程的启动过程。了解了shell的工作原理及其在处理命令行指令时的关键作用。接着研究了fork函数和execve函数的运作机制。其中,fork函数用于创建新的进程,而execve函数则允许一个进程替换其自身的执行映像为另一个新的程序。此外还探讨了上下文切换的机制,即在多任务处理中,当一个任务暂时挂起以便另一个任务可以运行时,系统如何保存和恢复进程的状态。



7hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:

逻辑地址是程序员在编程时使用的地址。它是相对于程序中某个特定段的偏移量或索引,通常由编译器生成。

线性地址:

线性地址是逻辑地址经过分段机制转换后得到的地址。在分段机制下,逻辑地址由两部分组成:段选择符和段内偏移量。线性地址是通过将段选择符乘以一个常数加上段内偏移量得到的。

虚拟地址:

虚拟地址是在程序运行时由操作系统分配的地址空间中使用的地址。它包括逻辑地址和线性地址。

物理地址:

物理地址是在计算机的内存硬件中实际存在的地址。操作系统通过使用页表或段表等数据结构将虚拟地址映射到物理地址。

在Hello代码中,主要关注的是逻辑地址和虚拟地址。程序员使用逻辑地址来引用命令行参数(argv[1] 和 argv[2]),而这些逻辑地址最终会被转换为虚拟地址,由操作系统进行管理。

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

Intel x86 架构采用了分段的内存管理方式。在这种体系结构下,逻辑地址(到线性地址的变换涉及到段选择符、描述符表以及段内偏移量。

图38:逻辑地址到线性地址的变换

以下是基本过程:

首先,由段标识符可以通过全局描述符表(GDT)或局部描述符表(LDT)获得段描述符之后,从段描述符中可以得到段基地址。

最后,段基地址与段内偏移两部分可以构成线性地址。

线性地址的形式即为[segment base address:offset]。

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

页式内存管理实现从线性地址到物理地址的映射,它把线性地址空间和物理地址空间分别划分为大小相同的块。这样的块称之为页。通过在线性地址空间的页与物理地址空间的页之间建立映射,实现线性地址到物理地址的转换。

具体过程如下图所示,首先线性地址在后可以分成虚拟页号(VPN)和虚拟页偏移量(VPO)两部分,而物理地址经过分页管理也分成物理页号(PPN)和物理页偏移量(PPO)两部分。其中MMU可以利用VPN找到对应的PPN,而VPO和PPO是相同的。故将找到的PPN与PPO结合就得到了物理地址。

图39:线性地址到物理地址的变换

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

页式管理通过将线性地址映射到页目录表和页表,最终通过计算物理地址来实现虚拟地址到物理地址的映射。这个过程中,需要访问多级页表结构,而每个页表项存储了对应的页框号。利用局部性原理,像缓存一样,将最近使用过的页表项专门缓存起来,之后找页表项的时候,先从缓存找,找不到在访问内存中的页表项。


图40:TLB与四级页表支持下的VA到PA的变换

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

在使用三级缓存支持的计算机体系结构中,CPU核心首先检查L1 Cache,然后L2 Cache,最后L3 Cache,以寻找所需的数据。如果在缓存中找到(命中),则加速访问;如果未命中,则从主内存加载数据到L3 Cache。这个多级缓存层次结构有效减少了主内存访问的延迟,提高了数据访问速度。同时,缓存替换和缓存一致性等机制保证了系统的正确性和性能。

图41: 三级Cache支持下的物理内存访问

7.6 hello进程fork时的内存映射

在fork创建子进程时,操作系统会通过内存映射机制复制父进程的地址空间到子进程中。这包括代码段、数据段和堆,使得子进程拥有独立的内存空间。尽管父子进程共享相同的物理内存,但由于采用写时复制(Copy-On-Write)策略,只有在某个进程尝试修改内存时才会实际进行复制,从而优化内存使用效率。这种机制确保了父子进程在创建时共享相同的程序和数据,但在执行过程中各自独立修改自己的内存副本。

7.7 hello进程execve时的内存映射

在execve系统调用时,发生了进程的映像替换,新程序的二进制映像替代了原有的进程。这导致了全新的内存映射。原有进程的代码段、数据段、堆等内容都被新程序的对应部分所替代,形成了新的内存映射。与fork不同的是,这一过程保留了原有的文件描述符表,但清空了原有进程的内存,为新程序的执行提供了独立的内存空间,实现了程序的更新和切换。

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

缺页故障指的是当程序访问的页面不在当前内存中时发生的情况。当发生缺页故障时,操作系统会引发缺页中断。缺页中断的处理过程包括以下步骤:首先,操作系统检查引发缺页中断的内存访问是否合法,如果是无效的内存访问,则终止进程。否则,操作系统从磁盘上获取所需的页面,将其载入到内存中,并更新页表以反映新的映射关系。最后,被中断的指令被重新执行,使程序能够继续执行。这一过程确保了程序在需要访问尚未加载到内存的页面时,能够通过中断处理机制将所需页面加载到内存中并继续执行。

图42:缺页中断处理

7.9动态存储分配管理

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

分配器有两种基本风格:

(1)显式分配器:要求应用显式地释放任何分配的块,例如C标准库提供的malloc程序包。

(2)隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,那么就是放这个块,也被称为垃圾收集器。

7.10本章小结

在这一章中,我们探讨了与“hello”程序相关的存储管理。首先解释了如何通过段式管理和页式管理将“hello”程序中的逻辑地址转换为线性地址,并最终转化为物理地址。接下来讨论了“hello”进程在调用fork和execve函数时的内存映射情况。这有助于读者理解进程间的内存管理差异和动态存储分配的实现。最后探讨了“hello”程序如何处理缺页故障异常,以及如何实现动态存储分配管理。包括程序如何有效地管理内存资源,以及在遇到异常情况时如何进行适当的处理。



8hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

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

8.2 简述Unix IO接口及其函数


Unix I/O(Input/Output)接口提供了一组函数,用于进行输入和输出操作。

1.open():打开文件并返回文件描述符。

2.close():关闭文件描述符。

3.read():从文件描述符读取数据。

4.write():向文件描述符写入数据。

5.lseek():设置文件描述符的偏移量。

6.fcntl():对文件描述符进行控制操作。

7.ioctl():设备控制。

8.pipe():创建管道。

8.3 printf的实现分析

printf 函数:

格式解析:printf 首先解析格式字符串,它包含普通字符和转换说明符(例如 %d、%s)。

参数列表:printf 函数通过可变参数列表获取传递给它的参数。可变参数列表的声明使用 ... 表示。

格式化输出:根据格式字符串中的转换说明符,printf 将相应的参数格式化为字符串,并输出到标准输出流(通常是终端)。

vsprintf 函数:

格式解析:与 printf 类似,vsprintf 也需要解析格式字符串。

参数列表:vsprintf 使用 va_list 类型的参数列表,它是一个用于处理可变参数的数据结构。

格式化输出:与 printf 不同的是,vsprintf 将格式化后的字符串写入一个字符数组中,而不是输出到标准输出。因此,它需要接收一个字符数组作为参数,以及一个 va_list 参数列表。

可变参数处理:通过 va_arg 宏从 va_list 中获取参数,然后按照格式字符串的说明符将其格式化为字符串。

8.4 getchar的实现分析

       getchar函数内部调用了read函数,通过系统调用read读取存储在键盘缓冲区的ASCII码,直到读到回车符才返回。不过read函数每次会把所有的内容读进缓冲区,如果缓冲区本来非空,则不会调用read函数,而是简简单单的返回缓冲区中最前面的元素

8.5本章小结

在这一章中探讨了Linux的I/O设备管理方法。首先介绍Unix的IO接口及其相关函数,然后分析了printf函数和getchar函数的实现。printf函数用于格式化输出,getchar函数则用于从标准输入读取一个字符。


结论

Hello程序的生命周期可以总结如下:

1.源代码编写:首先,开发者使用C语言编写Hello程序的源代码,形成hello.c文件。这是程序生命周期的起始点。

2.预处理:通过gcc -E命令,预处理器对源代码进行初步处理,将其转换为hello.i文件。这一阶段主要完成宏替换、条件编译等任务。

3.编译:在gcc -S命令下,编译器将预处理后的代码转换为汇编语言,形成hello.s文件。

4.汇编:通过gcc -c命令,汇编器将hello.s文件转换为目标文件hello.o,这是一个二进制文件,但尚未完成所有的链接操作。

5.链接:链接器将hello.o文件与所需的库文件和其他可重定位目标文件结合,生成一个可执行文件。此时,程序已经准备好在计算机上运行。

6.执行:用户在Shell中输入命令来运行程序。如果该命令不是Shell内置命令,Shell会将其解释为一个可执行文件的路径,并尝试执行它。

7.进程创建:如果需要,Shell会使用fork()系统调用创建一个新的进程来执行程序。

8.地址空间映射:新创建的进程通过execve()系统调用加载并执行Hello程序。该函数会替换当前进程的内存映像,并将Hello程序的代码和数据映射到进程的虚拟地址空间。

9.运行时环境:当Hello程序开始执行时,其地址空间由内存管理单元、TLB、多级页表机制以及三级缓存等硬件和软件组件共同管理,以确保地址正确翻译和数据访问。

10.输入与信号处理:用户可以通过输入设备向运行中的Hello进程发送信号。例如,通过按下Ctrl-c,操作系统向进程发送SIGINT信号来中断其执行。

11.进程终止与回收:当Hello程序执行完毕或接收到终止信号时,其进程结束执行。如果该进程是fork()产生的子进程,父进程会负责回收已终止的子进程资源。



附件



参考文献

[1]《深入理解计算机系统》Randal E. Bryant David R.O`Hallaron

[2]百度百科

[3]Shell简介:Bash的功能与解释过程(一) Shell简介

[4]段页式访存——逻辑地址到线性地址的转换

[5]printf函数实现的深入剖析

  • 26
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值