计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 医学与健康
学 号 2022110415
班 级 2252002
学 生 王文馨
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
Linux操作系统下,对C语言hello.c的进行研究,深入了解其完整的生命周期。包括预处理、编译、汇编、链接等过程及进程管理、存储管理、I/O管理等方面。本研究特别关注hello.c程序在Ubuntu系统上的执行过程,从而更深入地理解计算机系统。
关键词:计算机系统,C语言,程序,底层原理
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
Hello的P2P转化象征着hello.c文件从静态的编译产物(Compiled Product)到动态的进程实体(Process Entity)的华丽变身。在Linux系统的舞台上,hello.c文件首先历经cpp(C预处理器)的初步修饰、ccl(C编译器)的编译转换、as(汇编器)的汇编整合,以及ld(链接器)的最终链接,从而蜕变为一个独立的可执行文件hello(在Linux系统中,这个文件通常没有特定的后缀)。
当我们在shell命令行中输入./hello这个指令时,shell仿佛一位舞台导演,通过fork操作启动了新的进程,hello便从静态的可执行文件转化为了一个活跃的进程实体。
Hello的020旅程则暗喻了hello.c文件“从虚无到虚无”的完整循环。在开始时,内存中是一片空白的“虚无”,没有任何与hello相关的内容。然而,随着我们在shell中发出执行命令,execve函数如同一个魔法师,将hello文件加载到内存中,赋予其生命,代码开始执行。而当hello完成了它的使命,其进程也随之结束,如同舞台上的演出落下帷幕,hello的进程被系统回收,内存中的相关数据也被清除,一切回归到了最初的“虚无”。这便是hello.c文件“从虚无到虚无”的完整旅程。
1.2 环境与工具
硬件环境:Inter corei7处理器,2.5GHz,16GRAM
软件环境:Ubuntu
开发及调试工具:CodeBlocks;vi/vim/gpedit+gcc
1.3 中间结果
描述 | |
hello.i | 预处理后得到的ASCII码的中间文件 |
hello.s | 编译后得到的ASCII汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello1.elf | 用readelf读取hello.o得到的ELF格式信息 |
asm1.txt | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
asm2.txt | 反汇编hello可执行文件得到的反汇编文件 |
1.4 本章小结
本章简要介绍了hello的P2P,020的具体含义,同时列出了研究时采用的软硬件环境和中间结果程序。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
A.预处理的概念
预处理步骤是C语言编程中的一个重要环节,它发生在程序编译之前。预处理器(cpp,C Pre-Processor)会扫描源代码文件,查找以字符#开头的预处理指令,并根据这些指令对源代码进行必要的修改。例如,在hello.c文件的第7到9行中,#include指令会告诉预处理器读取并包含系统头文件如stdio.h、unistd.h、stdlib.h的内容,并将这些头文件的内容直接插入到源程序中。此外,预处理器还会用实际值替换通过#define定义的宏,同时删除源代码中的注释和不必要的空白字符。预处理完成后,通常会生成一个以.i为扩展名的ASCII码中间文件,该文件包含了预处理后的源代码。
B.预处理的作用
预处理的主要作用是为后续的编译过程提供一个已经过初步处理的源代码文件。通过#include指令,预处理器将所需的头文件内容直接插入到源程序中,从而确保编译器在编译时能够找到所有必要的声明和定义。此外,预处理器还负责宏的替换,这有助于简化代码并提高代码的可读性和可维护性。预处理过程虽然并不直接解析源代码的逻辑内容,但它通过文本插入与替换的方式,为后续的编译和链接过程打下了坚实的基础。生成的.i文件仍然是文本文件,但已经过预处理器的初步处理,更加适合后续的编译步骤。
2.2在Ubuntu下预处理的命令
在Ubuntu系统下,进行预处理的命令为: cpp hello.c > hello.i
2.3 Hello的预处理结果解析
可以发现hello.i程序已经拓展为3061行,只有最后一小部分是源文件的内容,其余均是在预处理过程中插入的内容。
CPP还会进行删除程序中的注释和多余的空白字符等操作,并对一些值进行替换。
2.4 本章小结
本章主要介绍了预处理的概念及作用、并结合hello.c文件预处理之后得到的hello.i程序对预处理结果进行了解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
3.1.1概念
编译是将高级编程语言(如C语言)的源代码转换成机器能够理解的低级指令(如汇编代码或二进制机器码)的过程。编译器如ccl负责进行词法分析、语法分析和语义分析,然后将源代码转化为汇编语言文件(如hello.s),该文件包含了低级机器语言指令的文本描述。
3.1.2作用
将文本文件翻译成汇编语言程序,为后续将其转化为二进制机器码做准备。
3.2 在Ubuntu下编译的命令
在Ubuntu系统下,进行预处理的命令为:
gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1数据类型
3.3.1.1整数
- int i
i为int型局部变量。编译器将局部变量存储在寄存器或者栈空间中。。i作为函数内部的局部变量,并不占用文件实际节的空间,只存在于运行时栈中。对于i的操作就是直接对寄存器或栈进行操作。
2.int argc
argc是main函数的第一个参数,64位编译下,由寄存器%rdi传入,进而保存在栈中。
3.立即数4
立即数4在汇编语句中直接以$4的形式出现,对应C源程序中if(argc!=4)
3.3.1.2字符串
hello.s中保存了两个字符串,分别为:
LC0、LC1都是字符串常量,储存在.text 数据段中。\XXX为UTF-8编码,一个汉字对应三个字节。
3.3.1.3数组
hello.c只存在一个数组,且没个元素都是一个指向字符类型的指针。数组起始位置存在在栈-32(%rbp)的位置。
3.3.2算术操作
在hello.s中,具体涉及的算数操作包括:
- subq $32, %rsp 开辟栈帧
- addq $16, %rax 修改地址偏移量
- addq $8, %rax 修改地址偏移量
- addq $24, %rax 修改地址偏移量
- addl $1, -4(%rbp) 实现 i++的操作
3.3.3赋值操作
当执行int i = 0;这样的赋值操作时,编译器会用mov语句来执行这个赋值。对于x86架构,并且当i是一个4字节的int类型变量时,编译器通常会使用movl指令来进行赋值。
3.3.4关系操作
检查argc是否不等于3。使用cmpl $4, -20(%rbp),比较 argc
与4的大小并设置条件码,为下一步je利用条件码进行跳转作准备。
检查i是否小于8。在hello.s中,使用cmpl $7, -4(%rbp)比较i与7的大小,然后设置条件码,为下一步jle利用条件码进行跳转做准备。
3.3.5控制转移
Cmpl比较argc与4是否相等,je则表示:如果相等,则跳转至.L2,不执行后续部分内容;若不等则继续执行函数体内部对应的汇编代码。
当i < 8时进行循环,每次循环i++。在hello.s中,使用cmpl $7, -4(%rbp),比较i与7是否相等,在 i<=7 时继续循环,进入.L4,i>7时跳出循环。
|
3.3.6类型转换
atoi完成从字符串到整数的类型转换,返回值保存到%rax中,再传入%rdi作为sleep的参数,接着调用sleep
3.3.7函数操作
在上图能看见,在C语言中采用call指令对函数main,printf,exit,sleep等进行调用。在调用过程中,一般会经过参数传递,函数调用,分配和释放内存等过程。
本章小结
本章介绍了编译的概念,即编译器将高级编程语言源代码转换为机器语言(二进制代码)的过程。通过hello.s等汇编文件,我们可以观察到编译器如何处理数据类型和操作,从而验证了这些概念在汇编层面的实现。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是指通过汇编器(assembler)将人类可读的汇编语言指令(通常以.s为扩展名的文件)转换为机器可以直接执行的二进制指令(机器语言),并将这些指令组织成一种特定的格式,即可重定位目标程序格式,最终生成一个可重定位的目标文件(通常以.o为扩展名)。
4.1.2 汇编的作用
汇编的作用在于将汇编语言源代码(.s文件)转化为机器语言,使计算机能够直接执行。这个过程中,汇编器将汇编指令翻译成对应的机器码,并将这些机器码以及相关的符号和元数据以可重定位目标程序格式存储在.o文件中,以便后续链接器(linker)能够将这些目标文件与其他文件组合成最终的可执行程序。
4.2 在Ubuntu下汇编的命令
在 Ubuntu 下汇编的命令为:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
汇编过程如下:
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
在shell中输入readelf -a hello.o > hello1.elf指令获得hello.o文件的ELF格式:
各节信息:
1.ELF头:以16字节的魔数(Magic Number)序列开头,这个序列标识了生成该ELF(可执行与可链接格式)文件的系统所使用的字的大小(32位或64位)以及字节的存储顺序(大端或小端)。ELF头的其余部分包含了对于链接器来说至关重要的信息,这些信息有助于链接器解析和解释目标文件的内容。这些信息包括ELF头本身的大小、目标文件的类型(如可重定位文件、可执行文件或共享对象文件)、机器架构的类型、节头部表(Section Header Table)在文件中的偏移量,以及节头部表中每个条目的大小和数量等关键元数据。
2.节头:ELF文件的节头部表记录了各个节的类型、位置和大小,这些信息对于链接器和加载器解析文件至关重要。
3..重定位节.rela.text:
在.text节中,存在一个重定位信息列表,这些信息指明了在链接过程中需要修改的地址,以便将当前目标文件与其他文件正确组合。这个列表包含了8条重定位条目,分别对应于对.L0(第一个printf中的字符串)、puts函数、exit函数、.L1(第二个printf中的字符串)、printf函数、atoi函数、sleep函数和getchar函数的引用,以确保这些外部符号在最终的链接结果中有正确的地址。
4.重定位节.rela.eh_frame:
5.符号表symbol table
符号表(Symbol Table)是存储程序中符号定义和引用信息的集合,这些符号包括变量名、函数名等。在链接和重定位过程中,符号表提供了必要的信息来解析和修正这些符号的引用,确保它们指向正确的内存地址或定义位置。所有需要重定位引用的符号都会在符号表中声明。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
对比hello.o的反汇编和hello.o,两者存在以下差异:
1.分支转移
|
在汇编代码中,跳转指令(如jmp)通常使用标签(如.L2, .L3)作为目标地址。然而,在反汇编得到的文本中,这些跳转的目标会被转换为具体的内存地址。在机器码层面,跳转指令通常编码为相对于当前指令下一条指令的偏移量,而不是直接的目标地址。
19+14=2d
36+46=7c
82+b4=(1)36
2.函数调用
在hello.s文件中,call指令后直接跟着函数名称,这是因为汇编器在汇编阶段还不知道这些函数在内存中的确切地址,特别是当它们位于共享库中时。
在反汇编得到的文本中,call指令的目标地址可能显示为当前指令的下一条指令的地址(或表示为全0的偏移量),这是因为此时函数的确切地址还未确定。由于这些函数是动态链接的,它们的地址在程序运行时才会被解析。
为了处理这种不确定性,汇编器会在.rela.text(或其他类似的重定位节)中添加重定位条目。这些条目包含了关于如何修正call指令目标地址的信息。当程序加载到内存中并准备执行时,动态链接器会读取这些重定位条目,并将call指令的目标地址修正为正确的函数地址。这样,程序在运行时就能正确地调用这些位于共享库中的函数了。
3..rodata数据访问:在hello.s中,使用段名称访问.rodata数据。反汇编的文本中,这些引用初始显示为0地址,因为链接前地址未确定。链接时,链接器会将这些地址替换为.rodata中数据的绝对地址,即进行重定位。
4.5 本章小结
本章介绍了汇编语言概念,并通过Ubuntu系统将hello.s文件转换为hello.o和hello.elf文件,研究ELF格式。通过比较hello.o的反汇编代码与hello.s,了解汇编与机器语言的异同。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是使用链接器将多个程序编码和数据块组合成一个可执行文件的过程,这个可执行文件在Windows系统中通常以.exe为扩展名,而在Linux系统中则通常没有特定的后缀名。
5.1.2 链接的作用
链接的主要作用是实现程序的模块化开发。通过将程序拆分为多个较小的源文件,可以实现以下优势:
1.降低复杂度:将程序分解为多个模块,每个模块负责特定的功能,降低了整体程序的复杂度。
2.方便修改:当需要修改某个功能时,只需要修改对应的源文件,重新链接即可,无需对整个程序进行编译。
3.提高容错性:由于模块之间的独立性,一个模块的错误通常不会影响其他模块的正常运行。
4.易于维护:模块化的结构使得程序更易于理解和维护,提高了代码的可读性和可维护性。
简而言之,链接通过实现程序的模块化,提高了程序的开发效率、可维护性和容错性。。
|
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
在shell中输入命令readelf -a hello > hello2.elf生成hello程序的ELF格式文件,保存为hello2.elf:
分析hello的ELF格式如下:
- ELF头(ELF Header):
hello2.elf 和 hello.elf 的 ELF 头信息相似,都从描述系统字大小和字节顺序的 Magic 开始。hello2.elf 的基本信息(如 Magic、类别)未变,但类型、程序头大小和节头数量可能增加,并获得了入口地址。
2.节头:
hello2.elf 中的节头(Section Headers)详细描述了文件中各个节的语义和属性,这些信息包括节的类型、在文件中的位置、偏移量以及大小等。与 hello1.elf 相比,hello2.elf 在经过链接处理后,节头内容会更为丰富和详细,这是因为链接过程中会添加新的节或对已有的节进行扩展,以适应可执行文件或共享库的需求。这些额外的节可能包含程序入口点、符号表、重定位表等,为程序加载和执行提供必要的信息。
3.程序头:
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
- Dynamic section:
5.Symbol table:
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
根据Linux x86-64的特性,虚拟地址空间的起始地址为0x400000。
由hello2.elf中的节头得到.interp段的起始地址为0x4002e0。
.text段的起始地址为0x4010f0:
|
.rodata段的起始地址为0x402000:
|
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
生成反汇编文件,与第四章中生成的反汇编文件进行比较,其不同之处如下:
1.链接后,程序通过添加@plt(过程链接表)条目等额外信息,为动态链接器在运行时解析外部函数(如puts, printf等)的地址做准备,导致函数数量在反汇编文件中看似增加。
2.链接器在链接过程中,会确定每个函数调用指令的目标地址,并修改这些指令的参数,使得它们能够直接跳转到目标函数在内存中的实际位置。这个修改是基于目标地址和紧随其后的指令地址之间的差值来完成的,以确保程序在运行时能够正确地执行函数调用。这样,链接器就生成了包含完整和正确地址信息的反汇编代码。
4011f9 + fffffe97 = (100)401090
401203 + fffffecd = (100)4010d0
3.跳转指令参数发生变化。
5.6 hello的执行流程
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
程序名称 | 程序地址 |
hello!_start | 0x4010f0 |
libc-2.31.so!__libc_start_main | 0x7febeca2af90 |
libc-2.31.so!__cxa_atexit | 0x7febeca4dde0 |
hello!__libc_csu_init | 0x401270 |
hello!_init | 0x401000 |
frame_dummy | 0x4011d0 |
hello!register_tm_clones | 0x401160 |
libc-2.31.so!_setjmp | 0x7fc8d54b9c80 |
libc-2.31.so!_sigsetjmp | 0x7f4d51077bb0 |
hello!main | 0x4011d6 |
printf@plt | 0x4010a0 |
atoi@plt | 0x4010c0 |
sleep@plt | 0x4010e0 |
getchar@plt | 0x4010b0 |
libc-2.31.so!exit | 0x7f08323daa40 |
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。
编译器在编译时无法预知函数在内存中的实际地址,因此添加重定位记录以指导动态链接器。动态链接器通过过程链接表(PLT)和全局偏移量表(GOT)在运行时解析函数地址。GOT存储函数的目标地址,而PLT使用GOT中的地址间接跳转到目标函数。动态链接器在加载时解析GOT条目,以确保包含正确的函数地址。这一过程避免了运行时对代码段的直接修改,采用了延迟绑定的策略。
由图可知:.got从地址0x403ff0开始。
在dl_init调用前,edb内容如下:
在dl_init调用后,edb内容如下:
|
5.8 本章小结
本章介绍了链接的概念,通过链接后的hello2.elf与未链接的hello.elf的对比,展现了链接过程中符号解析和地址重定位的作用。通过比较两个不同的反汇编文件,进一步加深了对重定位和动态链接机制的理解。链接确保了程序中的外部引用被正确解析,并转换为执行时所需的绝对地址。动态链接则允许程序在运行时加载和使用外部库中的函数。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的定义
进程是程序在计算机上执行时的具体实例,每个运行的程序都以进程的形式存在,并在其自己的上下文中执行。
6.1.2 进程的功能
进程为应用程序提供了两个关键的隔离机制:
1.逻辑控制流的隔离:通过为每个进程提供独立的逻辑控制流,系统能够给应用程序一种假象,即它们各自独占处理器资源,独立执行。这确保了进程之间的执行互不干扰。
2.地址空间的隔离:每个进程都拥有自己私有的地址空间,这意味着每个进程只能访问自己地址空间中的内存,无法直接访问其他进程的内存。这种隔离给应用程序一种假象,即它们独占地使用整个内存系统,从而确保了进程间的数据安全和隔离。
6.2 简述壳Shell-bash的作用与处理流程
在Linux系统中,Shell是一个交互式的用户接口程序,它代表用户来执行其他程序(它是命令行解释器,并且以用户模式运行的终端进程)。
Shell的基本功能在于解读并执行用户输入的指令,其工作流程如下:
(1) Shell从终端读取用户通过键盘输入的命令字符串。
(2) Shell分析这个命令字符串,提取出命令行参数,并构建一个适合传递给execve系统调用的参数向量(argv)。
(3) Shell检查第一个命令行参数(即命令本身)是否是其内置的Shell命令。
(4) 如果该命令不是Shell的内置命令,Shell会调用fork()系统调用来创建一个新的进程(子进程)。
(5) 在这个新创建的子进程中,Shell会使用之前提取出的参数,通过调用execve()系统调用来执行指定的程序。
(6) 如果用户没有指定在后台运行该命令(即命令末尾没有加上&符号),Shell会使用waitpid()(或wait())系统调用来等待该命令执行完毕后再返回用户提示符。
(7) 如果用户要求命令在后台运行(即命令末尾带有&符号),Shell会立即返回用户提示符,而不会等待该命令执行完毕。
6.3 Hello的fork进程创建过程
在Linux中,通过fork()系统调用创建新进程的过程简述如下:
首先,父进程执行fork()来创建一个子进程。这个子进程几乎完全复制了父进程的上下文,包括内存布局(栈、寄存器、程序计数器等)、环境变量以及打开的文件描述符。子进程与父进程的主要区别在于它们拥有不同的进程ID(PID)。
子进程随后可以执行任何它想要的任务,包括执行特定的程序,如当前目录下的hello可执行文件。当子进程完成其任务并退出时,如果父进程仍然存活,父进程将负责回收子进程的资源。否则,init进程(PID为1)会负责清理和回收这些资源。
6.4 Hello的execve过程
调用fork()创建子进程后,子进程会调用execve()来加载并执行新程序hello。execve()会替换当前进程的内存映像,包括代码、数据和堆栈,以加载hello程序。该函数不返回给原程序,而是直接在新程序的上下文中开始执行。新程序的代码和数据段被加载到内存,并且必要的资源(如打开的文件)被映射到进程地址空间。加载完成后,控制权转移到新程序的入口点(通常是_start),然后调用__libc_start_main来初始化C库并调用hello程序中的main函数。这样,子进程就成功加载并运行了新程序hello。
6.5 Hello的进程执行
当Shell执行fork为hello创建一个子进程时,这个子进程拥有独立的逻辑控制流。在hello运行过程中,若未被抢占,则正常执行;若被操作系统抢占,会进行上下文切换,暂停hello执行,转而调度其他进程。
当hello调用sleep函数时,为最大化处理器资源利用,sleep会请求内核将hello挂起,进行上下文切换,让其他进程运行。此时,hello被移入等待队列并开始计时。计时结束后,中断触发,hello被重新调度,从等待队列移出,恢复执行其逻辑控制流。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
1.在程序正常运行时,一共打印8次提示信息,乱按加回车对程序不产生影响,回收进程。
2.按下Ctrl + C,进程收到SIGINT信号,结束并回收hello进程。
3.按下Ctrl + Z,进程收到SIGSTP信号,shell显示提示信息并挂起hello进程。
4.ps jobs可查看hello进程的挂起
- pstree可查看所有进程的树状数
- kill可杀死进程
- 输入fg %1命令将hello进程再次调到前台执行,shell显示进程continue,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。
6.7本章小结
本章概述了进程概念、Shell-bash基础,并通过hello程序实例分析了fork和execve函数的运行机制,以及带参执行时的异常和信号处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
- 逻辑地址:
hello程序中的变量和函数位置通过段选择符和偏移量组成的逻辑地址来标识。 - 2.线性地址(或虚拟地址):
逻辑地址转换后得到线性地址,代表hello程序在连续地址空间中的位置。
3.虚拟地址:
与线性地址相同,是程序在逻辑上使用的地址,通过操作系统和硬件映射到物理内存。
4.物理地址:
物理地址是RAM中实际存储数据的地址,通过内存管理单元将虚拟地址转换为物理地址来访问。
7.2 Intel逻辑地址到线性地址的变换-段式管理
(以下格式自行编排,编辑时删除)
逻辑地址由段选择符和段内偏移量构成。段选择符是16位字段,其中前13位为索引号,后3位包含硬件相关细节。这样,逻辑地址就能准确地定位到内存中的特定位置。
|
如上图。
Intel处理器通过段式管理实现从逻辑地址到线性地址的转换。每个程序都维护一个段表,记录了程序各段在内存中的状态信息,如段号、起始地址、长度等。段寄存器中存储的段选择符用于查找段表,从而确定段的起始地址和访问权限。段选择符是一个16位的值,包括索引和表标识符,它指向全局或局部描述符表中的一个段描述符。通过索引,可以定位到段描述符,并从中获取段基址。结合偏移量,段基址形成线性地址,也称为虚拟地址。这样,处理器就能根据逻辑地址定位到物理内存中的实际位置。
7.3 Hello的线性地址到物理地址的变换-页式管理
(以下格式自行编排,编辑时删除)
CPU中的页表基址寄存器(PTBR)指向当前页表。虚拟地址分为虚拟页号(VPN)和虚拟页面偏移(VPO)。MMU使用VPN来选择页表项(PTE),将PTE中的物理页号(PPN)与VPO组合,形成物理地址。由于物理和虚拟页面的大小相同,物理页面偏移(PPO)与VPO相同。
PTE有效则页命中,直接取物理页号PPN与PPO组合成物理地址。PTE无效则发生缺页,由内核处理,选择牺牲页并加载新页。之后返回原进程,重新执行缺页指令,此时页命中,组合PPN与PPO得物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
(以下格式自行编排,编辑时删除)
TLB是一个虚拟寻址的小缓存,每行存储一个PTE块。TLB通常具有高相联度,通过VPN的位来选择组和匹配行。若TLB有T=2^t个组,TLB索引(TLBI)来自VPN的最低t位,而TLB标记(TLBT)则由VPN的其余位构成。这样,TLB能快速查找PTE以加速地址转换。
VPN的36位被分为四个9位的部分,每个部分作为不同层级页表的偏移量。CR3寄存器存储着第一级页表(L1)的物理地址。VPN的第一个9位用于定位L1页表中的一个PTE,该PTE包含第二级页表(L2)的基地址。VPN的第二个9位则用于定位L2页表中的一个PTE,以此类推。最终,在第四级页表中,PTE包含一个40位的物理页号(PPN),它与虚拟页面偏移量(PPO)结合,共同形成物理地址.
7.5 三级Cache支持下的物理内存访问
缓存(Cache)的访问过程相对直接,需要物理地址被分割为标记(Tag)、组索引(Set Index)和块偏移(Block Offset)三个部分。首先,我们使用组索引来确定地址在缓存中对应的组。随后,通过比较缓存中该组的标记和有效位,我们判断所需数据是否已缓存。
如果数据命中缓存(Cache Hit),则我们可以直接利用块偏移来读取目标数据。然而,如果数据未命中缓存(Cache Miss),则我们需要从下一级缓存(或主存)中查找该数据。请注意,下一级缓存并不总是传统意义上的缓存,例如对于L3缓存来说,其下一级通常是主存储器。
以Core i7 CPU的一级缓存(L1 Cache)为例,其大小为32KB,每组包含八路(8-way set associative),每个缓存块大小为64字节。经过计算,我们知道这个缓存共有64个组。由于Core i7 CPU使用52位物理地址,我们可以根据这个信息对物理地址进行划分,如图所示。这样,我们就可以有效地管理和利用缓存,从而提高数据访问的速度和效率。
经过内存管理单元(MMU)的转换,虚拟地址被映射为物理地址。计算机随后使用物理地址中的组索引在L1缓存中查找对应的缓存组。在找到缓存组后,计算机通过比较标记位来确认数据是否存在于该组中。如果标记位匹配成功,并且有效位指示该缓存块有效(值为1),则计算机使用块偏移量从缓存块中提取数据,并将其返回给CPU。
如果标记位不匹配或有效位指示缓存块无效(未命中),计算机则需要从下一级缓存(如L2)中重复上述搜索过程。如果数据在下一级缓存中命中,它会被加载到L1缓存中,以便未来的快速访问。
当需要将数据写回L1缓存时,如果L1中没有空闲的缓存块(即没有有效位为0的块),则必须选择一个缓存块进行替换。为了确定哪个缓存块最不可能在近期被再次访问,计算机通常采用最近最少使用(LRU)算法来选择一个缓存块进行替换(牺牲)。
对于L2和L3缓存的访问过程与L1缓存相同,都是利用物理地址的组索引、标记位和块偏移量来定位数据。这种层次化的缓存结构有助于优化数据访问性能,提高系统整体效率。
7.6 hello进程fork时的内存映射
当fork函数被hello进程调用时,内核会为新进程(子进程)hello(通常是父进程的副本)初始化一系列必要的数据结构,并赋予其一个独特的进程标识符(PID)。为了构建子进程的虚拟内存空间,内核会复制父进程的mm_struct(内存管理结构)、区域结构(通常指vm_area_struct,用于描述进程的虚拟内存区域)以及页表(Page Table)的当前状态。
在复制过程中,为了确保两个进程(父进程和子进程)之间的内存隔离,内核会将这两个进程中的每个页面都设置为只读,并将每个区域结构标记为私有的写时复制(Copy-On-Write, COW)。
当fork在子进程中返回时,子进程的虚拟内存布局与调用fork时父进程的虚拟 内存布局完全相同,但所有的页面都被设置为只读和写时复制。这意味着,在任一进程(父或子)尝试写入某个页面之前,这两个进程共享相同的物理内存页面。
然而,一旦任一进程尝试写入一个标记为写时复制的页面,内核就会触发写时复制机制,为该进程分配一个新的物理页面,并将写入操作重定向到这个新页面上。这样,原始的物理页面保持不变,而每个进程则拥有了自己私有的、可写的页面副本。通过这种方式,内核为每个进程维护了独立的地址空间,同时优化了内存使用,因为只有当页面实际被写入时,才会发生物理内存的复制。
7.7 hello进程execve时的内存映射
execve函数加载并运行hello程序时,确实需要经过一系列步骤来设置新的进程映像。以下是这些步骤的重新表述,确保意思不变:
清除现有用户空间
在加载新程序之前,execve会移除当前进程hello虚拟地址空间中用户部分的所有已存在区域(或称为段)。这些区域可能包含先前程序加载的代码、数据和其他资源。
创建新的私有区域
为了加载hello程序,execve会创建新的、私有的、写时复制(Copy-On-Write, COW)的区域结构来存放新程序的代码、数据、未初始化数据(bss段)以及栈。其中:
代码区域通常对应于hello程序文件中的.text部分,它包含程序的机器指令。
数据区域通常对应于.data部分,包含程序初始化时需要的静态数据。
bss区域是未初始化的数据段,它的大小在hello程序的元数据中有定义,但实际内存空间会被分配为全零。
栈区域用于程序执行时的函数调用和局部变量存储,初始时大小可能为零,但会随着程序运行而增长。
映射共享库
如果hello程序依赖于共享对象或目标文件(如标准C库
libc.so),execve会负责将这些共享库动态链接到hello程序,并将它们映射到用户虚拟地址空间中的共享区域。这样,多个进程可以共享相同的库代码和数据,从而节省内存。
设置程序执行起点
最后,execve会更新当前进程上下文的程序计数器(Program Counter, PC),使其指向新程序代码区域的入口点。这通常是hello程序文件.text部分的开始处,即程序的入口函数(如main函数)的地址。一旦程序计数器被设置,当进程恢复执行时,它将开始执行新加载的hello程序。
7.8 缺页故障与缺页中断处理
当内存管理单元(MMU)在尝试翻译某个虚拟地址A时遇到缺页异常,这个异常会触发控制权的转移至内核的缺页处理程序。处理程序会执行以下步骤来应对这一事件:
1.虚拟地址的合法性:
缺页处理程序会遍历区域结构(通常称为段或虚拟内存区域)的链表,将虚拟地址A与每个区域结构的起始和结束地址进行比较。如果A不属于任何区域结构,即没有匹配到任何区域,那么地址A被视为非法,此时会触发一个段错误(segmentation fault),并可能终止进程。
2.检查内存访问的权限:
如果虚拟地址A是合法的,但试图进行的内存访问(如写操作到一个只读区域)是不允许的,那么缺页处理程序会触发一个保护异常(protection fault)。这通常会导致进程被终止,因为它违反了内存保护规则。
3.处理缺页:
如果缺页异常是对合法地址进行合法访问时出现的,缺页处理程序就会开始处理缺页。这通常包括以下步骤:
选择牺牲页面:内核会选择一个页面来替换(或称为“牺牲”),以便为新页面腾出空间。这个页面可能是最近最少使用(LRU)的页面,或者是根据其他页面替换算法选择的。
交换页面(如果需要):如果选中的牺牲页面已被修改(即它是“脏的”),内核需要将它写回到磁盘(即“交换出去”或“页出”),以便保存它的内容。
分配新页面:一旦牺牲页面被处理(如果需要),内核会为新页面分配物理内存,并更新页表以反映这一变化。
更新页表:最后,内核会更新页表条目,以确保虚拟地址A现在映射到正确的物理地址。
4.重启指令:
当缺页处理程序完成所有必要的操作并返回时,CPU会重新启动引起缺页的指令。这次,由于页表已经更新,MMU能够正常地翻译虚拟地址A,并允许指令继续执行。
这个过程确保了当进程尝试访问尚未加载到物理内存中的页面时,系统能够透明地处理这种情况,而无需用户干预或中断进程的正常执行。
7.9动态存储分配管理
动态内存分配器负责管理一个进程的虚拟内存区域(通常称为“堆”),它将这些内存视为由不同大小的连续虚拟内存块组成的集合。这些块要么已经被分配给应用程序使用(称为“已分配”块),要么还未被分配且可供将来使用(称为“空闲”块)。
内存分配器有两种主要风格:
1.显式分配器:在这种风格中,应用程序需要明确地请求和释放内存块。C标准库提供了一个名为malloc的函数包来实现这种显式分配器。当C程序需要内存时,它会调用malloc函数来分配一个块;而当不再需要这个块时,它会调用free函数来释放它。在操作系统层面,内核通过调用sbrk(或其他类似函数)来扩展或收缩堆的大小,以满足应用程序的内存请求。
2.隐式分配器(或垃圾收集器):与显式分配器不同,隐式分配器会自动检测何时一个已分配的内存块不再被程序使用,并自动释放它。这种分配器通常被称为“垃圾收集器”,因为它们会“收集”那些不再需要的内存块,并将其返回给系统或用于其他目的。垃圾收集器通过跟踪内存的使用情况并检测何时内存块不再被引用来实现这一功能。
简而言之,显式分配器要求程序员手动管理内存的生命周期,而隐式分配器则自动管理内存,减轻了程序员的负担。
7.10本章小结
1.hello程序的存储器地址空间:描述了hello程序在内存中的布局,包括代码段、数据段、堆和栈等。
2.Intel的段式管理:介绍了Intel处理器如何使用段式内存管理来组织和保护内存。
3.hello的页式管理:详细说明了操作系统如何使用页式内存管理来分配和管理hello程序的内存空间。
4.VA到PA的变换:阐述了如何将虚拟地址(VA)转换为物理地址(PA),以便CPU能够访问实际的物理内存。
5.物理内存访问:解释了如何通过物理地址直接访问内存中的数据。
6.hello进程fork、execve时的内存映射:分析了fork和execve系统调用如何影响hello进程的内存映射和布局。
7.缺页故障与缺页中断处理:探讨了当CPU试图访问一个尚未加载到物理内存中的页面时,如何触发缺页故障和中断,以及操作系统如何处理这些故障。
8.动态存储分配管理:讨论了如何在程序运行时动态地分配和释放内存,以满足程序的动态内存需求。(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
在Linux系统中,IO设备被抽象化为文件的概念,这种设计使得所有的输入和输出操作都可以被看作是对特定文件的读取和写入。通过将设备映射为文件,Linux内核为应用程序提供了一个简洁、底层的接口,通常被称为Unix I/O。这种设计使得无论是文件、磁盘、网络套接字还是其他类型的IO设备,都可以使用相同的、一致的方式来处理:打开设备文件、调整文件指针位置(如果需要的话)、读取或写入数据、最后关闭文件。这样的抽象极大地简化了IO操作的管理,并为开发者提供了统一且易于理解的编程接口。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O 接口概述:
文件开启。应用程序通过请求内核开启特定文件来表明其意图访问某个I/O设备。内核随后返回一个非负整数作为文件描述符,用于后续对该文件的所有操作。内核负责记录与该打开文件相关的所有信息,而应用程序只需记住这个描述符。
初始文件。Linux shell创建的每个进程开始时默认拥有三个已打开的文件:标准输入(文件描述符为0)、标准输出(文件描述符为1)和标准错误(文件描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,这些常量可用作文件描述符的替代值。
文件位置调整。对于每个已打开的文件,内核维护一个文件位置k,初始值为0。这个位置表示从文件开头起的字节偏移量。应用程序可以通过执行seek操作来显式地设置文件的当前位置为k。
数据读写。读操作意味着从当前文件位置k开始,将文件中的n>0个字节复制到内存中,并将k增加n。当尝试读取一个大小为m字节的文件,且k≥m时,会触发一个称为“文件结束”(EOF)的条件,应用程序能够检测到这一条件。值得注意的是,文件末尾并没有明确的“EOF符号”。类似地,写操作则是从内存中将n>0个字节复制到文件中,从当前位置k开始,并更新k的值。
文件关闭。当应用程序完成对文件的访问后,它会通知内核关闭该文件。作为响应,内核会释放文件打开时创建的数据结构,并将该描述符释放回可用的描述符池中。无论进程因何原因终止,内核都会确保所有打开的文件被关闭并释放相关资源。
8.2.2 Unix I/O 函数详解:
int open(char* filename, int flags, mode_t mode)
进程通过调用open函数来打开已存在的文件或创建新文件。open函数将filename转换为文件描述符,并返回该描述符的数值。返回的描述符通常是当前进程中尚未使用的最小描述符。flags参数指定了进程对文件的访问方式,而mode参数则设置了新文件的访问权限。
int close(int fd)
close函数用于关闭指定的文件描述符fd,并返回操作结果。
ssize_t read(int fd, void *buf, size_t n)
read函数从文件描述符fd指向的文件的当前位置读取最多n个字节的数据,并将这些数据存储到内存地址buf指向的位置。如果发生错误,函数返回-1;如果达到文件末尾(EOF),则返回0;否则返回实际读取的字节数。
ssize_t write(int fd, const void *buf, size_t n)
write函数将内存地址buf指向的最多n个字节的数据写入到文件描述符fd指向的文件的当前位置。函数返回实际写入的字节数,如果发生错误则返回-1。
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
printf函数的函数体如下:
在描述可变参数函数和字符显示流程时,以下是您所描述内容的另一种表达方式:
函数在处理可变参数列表时,使用了...来表示参数的数量和类型是不确定的。va_list实际上是一个通过typedef定义的类型,它通常被实现为指向char的指针。在函数中,为了访问这些可变参数,通常会使用宏如va_start、va_arg和va_end来初始化和遍历参数列表。
在您给出的例子中,va_list arg = (va_list)((char*)(&fmt) + 4); 这句代码(尽管不是标准做法,但为了说明)试图跳过某个固定大小的参数(假设是fmt指针),以获取可变参数列表中的第一个参数。然而,正确的做法是使用va_start宏来初始化arg,并通过va_arg宏来获取每个参数。
接着,程序调用vsprintf函数,该函数根据提供的格式字符串(和通过可变参数列表传递的其他参数)来格式化字符串,并将结果存放在buf缓冲区中。vsprintf返回写入的字符数量i。
然后,程序使用系统调用write(通常是通过系统调用接口如syscall或int 0x80在x86体系结构中)将buf缓冲区中的前i个字符发送到标准输出(通常是终端或控制台)。
在底层,字符显示涉及到一个字符显示驱动子程序。这个驱动子程序负责将ASCII字符从用户空间的应用程序转换为图形界面可以理解的字模(bitmap)或字形(font),并将这些信息存储在视频RAM(VRAM)中。VRAM存储了每个像素点的颜色信息(如RGB分量)。
显示芯片根据预先设定的刷新频率,逐行从VRAM中读取像素数据,并通过信号线将这些数据发送到液晶显示器。液晶显示器根据接收到的RGB分量来显示每个像素点,从而呈现出字符和图像。
最终,程序返回实际写入的字符数量i,这个值通常用于确认或进一步处理输出。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
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;
}
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar调用系统函数read,发送一个中断信号,内核抢占这个进程,用户输入字符串,键入回车后(字符串和回车都保存在缓冲区内),再次发送信号,内核重新调度这个进程,getchar从缓冲区读入字符。
8.5本章小结
本章介绍了Linux系统如何管理IO设备,包括其接口和函数。同时,对printf和getchar函数的底层实现有了基本了解,这些函数依赖于Linux的IO设备管理机制,如通过write和read系统调用来实现数据的读写。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello程序的一生经历了如下过程:
1. 预处理
将hello.c中include的所有外部的头文件头文件内容直接插入程序文本中,完成字符串的替换,得到hello.i,方便后续处理;
2. 编译
通过词法分析和语法分析,将合法指令翻译成等价汇编代码。通过编译过程,编译器将hello.i翻译成汇编语言文件hello.s;
3. 汇编
将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o目标文件中;
4. 链接
通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello;
5. 加载运行
打开shell,在其中输入./hello 2022113557 宫名扬 1,终端为其fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行;
6. 执行指令
在该进程被调度时,CPU为hello其分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器逐步更新,CPU按照控制逻辑流执行指令;
7. 访存
内存管理单元MMU将逻辑地址,一步步映射成物理地址,进而通过三级高速缓存系统访问物理内存中的数据;
8. 动态申请内存
printf会调用malloc向动态内存分配器申请堆中的内存;
9. 信号处理
进程时刻等待着信号,如果运行途中键入ctrl-c ctrl-z则调用shell的信号处理函数分别进行停止、挂起等操作,对于其他信号也有相应的操作;
10. 终止并被回收
shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 描述 |
hello.i | 预处理后得到的ASCII码的中间文件 |
hello.s | 编译后得到的ASCII汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello1.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 医学与健康
学 号 2022110415
班 级 2252002
学 生 王文馨
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
摘 要
Linux操作系统下,对C语言hello.c的进行研究,深入了解其完整的生命周期。包括预处理、编译、汇编、链接等过程及进程管理、存储管理、I/O管理等方面。本研究特别关注hello.c程序在Ubuntu系统上的执行过程,从而更深入地理解计算机系统。
关键词:计算机系统,C语言,程序,底层原理
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
Hello的P2P转化象征着hello.c文件从静态的编译产物(Compiled Product)到动态的进程实体(Process Entity)的华丽变身。在Linux系统的舞台上,hello.c文件首先历经cpp(C预处理器)的初步修饰、ccl(C编译器)的编译转换、as(汇编器)的汇编整合,以及ld(链接器)的最终链接,从而蜕变为一个独立的可执行文件hello(在Linux系统中,这个文件通常没有特定的后缀)。
当我们在shell命令行中输入./hello这个指令时,shell仿佛一位舞台导演,通过fork操作启动了新的进程,hello便从静态的可执行文件转化为了一个活跃的进程实体。
Hello的020旅程则暗喻了hello.c文件“从虚无到虚无”的完整循环。在开始时,内存中是一片空白的“虚无”,没有任何与hello相关的内容。然而,随着我们在shell中发出执行命令,execve函数如同一个魔法师,将hello文件加载到内存中,赋予其生命,代码开始执行。而当hello完成了它的使命,其进程也随之结束,如同舞台上的演出落下帷幕,hello的进程被系统回收,内存中的相关数据也被清除,一切回归到了最初的“虚无”。这便是hello.c文件“从虚无到虚无”的完整旅程。
1.2 环境与工具
硬件环境:Inter corei7处理器,2.5GHz,16GRAM
软件环境:Ubuntu
开发及调试工具:CodeBlocks;vi/vim/gpedit+gcc
1.3 中间结果
文件名 | 描述 |
hello.i | 预处理后得到的ASCII码的中间文件 |
hello.s | 编译后得到的ASCII汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello1.elf | 用readelf读取hello.o得到的ELF格式信息 |
asm1.txt | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
asm2.txt | 反汇编hello可执行文件得到的反汇编文件 |
1.4 本章小结
本章简要介绍了hello的P2P,020的具体含义,同时列出了研究时采用的软硬件环境和中间结果程序。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
A.预处理的概念
预处理步骤是C语言编程中的一个重要环节,它发生在程序编译之前。预处理器(cpp,C Pre-Processor)会扫描源代码文件,查找以字符#开头的预处理指令,并根据这些指令对源代码进行必要的修改。例如,在hello.c文件的第7到9行中,#include指令会告诉预处理器读取并包含系统头文件如stdio.h、unistd.h、stdlib.h的内容,并将这些头文件的内容直接插入到源程序中。此外,预处理器还会用实际值替换通过#define定义的宏,同时删除源代码中的注释和不必要的空白字符。预处理完成后,通常会生成一个以.i为扩展名的ASCII码中间文件,该文件包含了预处理后的源代码。
B.预处理的作用
预处理的主要作用是为后续的编译过程提供一个已经过初步处理的源代码文件。通过#include指令,预处理器将所需的头文件内容直接插入到源程序中,从而确保编译器在编译时能够找到所有必要的声明和定义。此外,预处理器还负责宏的替换,这有助于简化代码并提高代码的可读性和可维护性。预处理过程虽然并不直接解析源代码的逻辑内容,但它通过文本插入与替换的方式,为后续的编译和链接过程打下了坚实的基础。生成的.i文件仍然是文本文件,但已经过预处理器的初步处理,更加适合后续的编译步骤。
2.2在Ubuntu下预处理的命令
在Ubuntu系统下,进行预处理的命令为: cpp hello.c > hello.i
2.3 Hello的预处理结果解析
可以发现hello.i程序已经拓展为3061行,只有最后一小部分是源文件的内容,其余均是在预处理过程中插入的内容。
CPP还会进行删除程序中的注释和多余的空白字符等操作,并对一些值进行替换。
2.4 本章小结
本章主要介绍了预处理的概念及作用、并结合hello.c文件预处理之后得到的hello.i程序对预处理结果进行了解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
3.1.1概念
编译是将高级编程语言(如C语言)的源代码转换成机器能够理解的低级指令(如汇编代码或二进制机器码)的过程。编译器如ccl负责进行词法分析、语法分析和语义分析,然后将源代码转化为汇编语言文件(如hello.s),该文件包含了低级机器语言指令的文本描述。
3.1.2作用
将文本文件翻译成汇编语言程序,为后续将其转化为二进制机器码做准备。
3.2 在Ubuntu下编译的命令
在Ubuntu系统下,进行预处理的命令为:
gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1数据类型
3.3.1.1整数
- int i
i为int型局部变量。编译器将局部变量存储在寄存器或者栈空间中。。i作为函数内部的局部变量,并不占用文件实际节的空间,只存在于运行时栈中。对于i的操作就是直接对寄存器或栈进行操作。
2.int argc
argc是main函数的第一个参数,64位编译下,由寄存器%rdi传入,进而保存在栈中。
3.立即数4
立即数4在汇编语句中直接以$4的形式出现,对应C源程序中if(argc!=4)
3.3.1.2字符串
hello.s中保存了两个字符串,分别为:
LC0、LC1都是字符串常量,储存在.text 数据段中。\XXX为UTF-8编码,一个汉字对应三个字节。
3.3.1.3数组
hello.c只存在一个数组,且没个元素都是一个指向字符类型的指针。数组起始位置存在在栈-32(%rbp)的位置。
3.3.2算术操作
在hello.s中,具体涉及的算数操作包括:
- subq $32, %rsp 开辟栈帧
- addq $16, %rax 修改地址偏移量
- addq $8, %rax 修改地址偏移量
- addq $24, %rax 修改地址偏移量
- addl $1, -4(%rbp) 实现 i++的操作
3.3.3赋值操作
当执行int i = 0;这样的赋值操作时,编译器会用mov语句来执行这个赋值。对于x86架构,并且当i是一个4字节的int类型变量时,编译器通常会使用movl指令来进行赋值。
3.3.4关系操作
检查argc是否不等于3。使用cmpl $4, -20(%rbp),比较 argc
与4的大小并设置条件码,为下一步je利用条件码进行跳转作准备。
检查i是否小于8。在hello.s中,使用cmpl $7, -4(%rbp)比较i与7的大小,然后设置条件码,为下一步jle利用条件码进行跳转做准备。
3.3.5控制转移
Cmpl比较argc与4是否相等,je则表示:如果相等,则跳转至.L2,不执行后续部分内容;若不等则继续执行函数体内部对应的汇编代码。
当i < 8时进行循环,每次循环i++。在hello.s中,使用cmpl $7, -4(%rbp),比较i与7是否相等,在 i<=7 时继续循环,进入.L4,i>7时跳出循环。
|
3.3.6类型转换
atoi完成从字符串到整数的类型转换,返回值保存到%rax中,再传入%rdi作为sleep的参数,接着调用sleep
3.3.7函数操作
在上图能看见,在C语言中采用call指令对函数main,printf,exit,sleep等进行调用。在调用过程中,一般会经过参数传递,函数调用,分配和释放内存等过程。
本章小结
本章介绍了编译的概念,即编译器将高级编程语言源代码转换为机器语言(二进制代码)的过程。通过hello.s等汇编文件,我们可以观察到编译器如何处理数据类型和操作,从而验证了这些概念在汇编层面的实现。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是指通过汇编器(assembler)将人类可读的汇编语言指令(通常以.s为扩展名的文件)转换为机器可以直接执行的二进制指令(机器语言),并将这些指令组织成一种特定的格式,即可重定位目标程序格式,最终生成一个可重定位的目标文件(通常以.o为扩展名)。
4.1.2 汇编的作用
汇编的作用在于将汇编语言源代码(.s文件)转化为机器语言,使计算机能够直接执行。这个过程中,汇编器将汇编指令翻译成对应的机器码,并将这些机器码以及相关的符号和元数据以可重定位目标程序格式存储在.o文件中,以便后续链接器(linker)能够将这些目标文件与其他文件组合成最终的可执行程序。
4.2 在Ubuntu下汇编的命令
在 Ubuntu 下汇编的命令为:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
汇编过程如下:
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
在shell中输入readelf -a hello.o > hello1.elf指令获得hello.o文件的ELF格式:
各节信息:
1.ELF头:以16字节的魔数(Magic Number)序列开头,这个序列标识了生成该ELF(可执行与可链接格式)文件的系统所使用的字的大小(32位或64位)以及字节的存储顺序(大端或小端)。ELF头的其余部分包含了对于链接器来说至关重要的信息,这些信息有助于链接器解析和解释目标文件的内容。这些信息包括ELF头本身的大小、目标文件的类型(如可重定位文件、可执行文件或共享对象文件)、机器架构的类型、节头部表(Section Header Table)在文件中的偏移量,以及节头部表中每个条目的大小和数量等关键元数据。
2.节头:ELF文件的节头部表记录了各个节的类型、位置和大小,这些信息对于链接器和加载器解析文件至关重要。
3..重定位节.rela.text:
在.text节中,存在一个重定位信息列表,这些信息指明了在链接过程中需要修改的地址,以便将当前目标文件与其他文件正确组合。这个列表包含了8条重定位条目,分别对应于对.L0(第一个printf中的字符串)、puts函数、exit函数、.L1(第二个printf中的字符串)、printf函数、atoi函数、sleep函数和getchar函数的引用,以确保这些外部符号在最终的链接结果中有正确的地址。
4.重定位节.rela.eh_frame:
5.符号表symbol table
符号表(Symbol Table)是存储程序中符号定义和引用信息的集合,这些符号包括变量名、函数名等。在链接和重定位过程中,符号表提供了必要的信息来解析和修正这些符号的引用,确保它们指向正确的内存地址或定义位置。所有需要重定位引用的符号都会在符号表中声明。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
对比hello.o的反汇编和hello.o,两者存在以下差异:
1.分支转移
|
在汇编代码中,跳转指令(如jmp)通常使用标签(如.L2, .L3)作为目标地址。然而,在反汇编得到的文本中,这些跳转的目标会被转换为具体的内存地址。在机器码层面,跳转指令通常编码为相对于当前指令下一条指令的偏移量,而不是直接的目标地址。
19+14=2d
36+46=7c
82+b4=(1)36
2.函数调用
在hello.s文件中,call指令后直接跟着函数名称,这是因为汇编器在汇编阶段还不知道这些函数在内存中的确切地址,特别是当它们位于共享库中时。
在反汇编得到的文本中,call指令的目标地址可能显示为当前指令的下一条指令的地址(或表示为全0的偏移量),这是因为此时函数的确切地址还未确定。由于这些函数是动态链接的,它们的地址在程序运行时才会被解析。
为了处理这种不确定性,汇编器会在.rela.text(或其他类似的重定位节)中添加重定位条目。这些条目包含了关于如何修正call指令目标地址的信息。当程序加载到内存中并准备执行时,动态链接器会读取这些重定位条目,并将call指令的目标地址修正为正确的函数地址。这样,程序在运行时就能正确地调用这些位于共享库中的函数了。
3..rodata数据访问:在hello.s中,使用段名称访问.rodata数据。反汇编的文本中,这些引用初始显示为0地址,因为链接前地址未确定。链接时,链接器会将这些地址替换为.rodata中数据的绝对地址,即进行重定位。
4.5 本章小结
本章介绍了汇编语言概念,并通过Ubuntu系统将hello.s文件转换为hello.o和hello.elf文件,研究ELF格式。通过比较hello.o的反汇编代码与hello.s,了解汇编与机器语言的异同。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是使用链接器将多个程序编码和数据块组合成一个可执行文件的过程,这个可执行文件在Windows系统中通常以.exe为扩展名,而在Linux系统中则通常没有特定的后缀名。
5.1.2 链接的作用
链接的主要作用是实现程序的模块化开发。通过将程序拆分为多个较小的源文件,可以实现以下优势:
1.降低复杂度:将程序分解为多个模块,每个模块负责特定的功能,降低了整体程序的复杂度。
2.方便修改:当需要修改某个功能时,只需要修改对应的源文件,重新链接即可,无需对整个程序进行编译。
3.提高容错性:由于模块之间的独立性,一个模块的错误通常不会影响其他模块的正常运行。
4.易于维护:模块化的结构使得程序更易于理解和维护,提高了代码的可读性和可维护性。
简而言之,链接通过实现程序的模块化,提高了程序的开发效率、可维护性和容错性。。
|
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
在shell中输入命令readelf -a hello > hello2.elf生成hello程序的ELF格式文件,保存为hello2.elf:
分析hello的ELF格式如下:
- ELF头(ELF Header):
hello2.elf 和 hello.elf 的 ELF 头信息相似,都从描述系统字大小和字节顺序的 Magic 开始。hello2.elf 的基本信息(如 Magic、类别)未变,但类型、程序头大小和节头数量可能增加,并获得了入口地址。
2.节头:
hello2.elf 中的节头(Section Headers)详细描述了文件中各个节的语义和属性,这些信息包括节的类型、在文件中的位置、偏移量以及大小等。与 hello1.elf 相比,hello2.elf 在经过链接处理后,节头内容会更为丰富和详细,这是因为链接过程中会添加新的节或对已有的节进行扩展,以适应可执行文件或共享库的需求。这些额外的节可能包含程序入口点、符号表、重定位表等,为程序加载和执行提供必要的信息。
3.程序头:
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
- Dynamic section:
5.Symbol table:
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
根据Linux x86-64的特性,虚拟地址空间的起始地址为0x400000。
由hello2.elf中的节头得到.interp段的起始地址为0x4002e0。
.text段的起始地址为0x4010f0:
|
.rodata段的起始地址为0x402000:
|
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
生成反汇编文件,与第四章中生成的反汇编文件进行比较,其不同之处如下:
1.链接后,程序通过添加@plt(过程链接表)条目等额外信息,为动态链接器在运行时解析外部函数(如puts, printf等)的地址做准备,导致函数数量在反汇编文件中看似增加。
2.链接器在链接过程中,会确定每个函数调用指令的目标地址,并修改这些指令的参数,使得它们能够直接跳转到目标函数在内存中的实际位置。这个修改是基于目标地址和紧随其后的指令地址之间的差值来完成的,以确保程序在运行时能够正确地执行函数调用。这样,链接器就生成了包含完整和正确地址信息的反汇编代码。
4011f9 + fffffe97 = (100)401090
401203 + fffffecd = (100)4010d0
3.跳转指令参数发生变化。
5.6 hello的执行流程
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
程序名称 | 程序地址 |
hello!_start | 0x4010f0 |
libc-2.31.so!__libc_start_main | 0x7febeca2af90 |
libc-2.31.so!__cxa_atexit | 0x7febeca4dde0 |
hello!__libc_csu_init | 0x401270 |
hello!_init | 0x401000 |
frame_dummy | 0x4011d0 |
hello!register_tm_clones | 0x401160 |
libc-2.31.so!_setjmp | 0x7fc8d54b9c80 |
libc-2.31.so!_sigsetjmp | 0x7f4d51077bb0 |
hello!main | 0x4011d6 |
printf@plt | 0x4010a0 |
atoi@plt | 0x4010c0 |
sleep@plt | 0x4010e0 |
getchar@plt | 0x4010b0 |
libc-2.31.so!exit | 0x7f08323daa40 |
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。
编译器在编译时无法预知函数在内存中的实际地址,因此添加重定位记录以指导动态链接器。动态链接器通过过程链接表(PLT)和全局偏移量表(GOT)在运行时解析函数地址。GOT存储函数的目标地址,而PLT使用GOT中的地址间接跳转到目标函数。动态链接器在加载时解析GOT条目,以确保包含正确的函数地址。这一过程避免了运行时对代码段的直接修改,采用了延迟绑定的策略。
由图可知:.got从地址0x403ff0开始。
在dl_init调用前,edb内容如下:
在dl_init调用后,edb内容如下:
|
5.8 本章小结
本章介绍了链接的概念,通过链接后的hello2.elf与未链接的hello.elf的对比,展现了链接过程中符号解析和地址重定位的作用。通过比较两个不同的反汇编文件,进一步加深了对重定位和动态链接机制的理解。链接确保了程序中的外部引用被正确解析,并转换为执行时所需的绝对地址。动态链接则允许程序在运行时加载和使用外部库中的函数。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的定义
进程是程序在计算机上执行时的具体实例,每个运行的程序都以进程的形式存在,并在其自己的上下文中执行。
6.1.2 进程的功能
进程为应用程序提供了两个关键的隔离机制:
1.逻辑控制流的隔离:通过为每个进程提供独立的逻辑控制流,系统能够给应用程序一种假象,即它们各自独占处理器资源,独立执行。这确保了进程之间的执行互不干扰。
2.地址空间的隔离:每个进程都拥有自己私有的地址空间,这意味着每个进程只能访问自己地址空间中的内存,无法直接访问其他进程的内存。这种隔离给应用程序一种假象,即它们独占地使用整个内存系统,从而确保了进程间的数据安全和隔离。
6.2 简述壳Shell-bash的作用与处理流程
在Linux系统中,Shell是一个交互式的用户接口程序,它代表用户来执行其他程序(它是命令行解释器,并且以用户模式运行的终端进程)。
Shell的基本功能在于解读并执行用户输入的指令,其工作流程如下:
(1) Shell从终端读取用户通过键盘输入的命令字符串。
(2) Shell分析这个命令字符串,提取出命令行参数,并构建一个适合传递给execve系统调用的参数向量(argv)。
(3) Shell检查第一个命令行参数(即命令本身)是否是其内置的Shell命令。
(4) 如果该命令不是Shell的内置命令,Shell会调用fork()系统调用来创建一个新的进程(子进程)。
(5) 在这个新创建的子进程中,Shell会使用之前提取出的参数,通过调用execve()系统调用来执行指定的程序。
(6) 如果用户没有指定在后台运行该命令(即命令末尾没有加上&符号),Shell会使用waitpid()(或wait())系统调用来等待该命令执行完毕后再返回用户提示符。
(7) 如果用户要求命令在后台运行(即命令末尾带有&符号),Shell会立即返回用户提示符,而不会等待该命令执行完毕。
6.3 Hello的fork进程创建过程
在Linux中,通过fork()系统调用创建新进程的过程简述如下:
首先,父进程执行fork()来创建一个子进程。这个子进程几乎完全复制了父进程的上下文,包括内存布局(栈、寄存器、程序计数器等)、环境变量以及打开的文件描述符。子进程与父进程的主要区别在于它们拥有不同的进程ID(PID)。
子进程随后可以执行任何它想要的任务,包括执行特定的程序,如当前目录下的hello可执行文件。当子进程完成其任务并退出时,如果父进程仍然存活,父进程将负责回收子进程的资源。否则,init进程(PID为1)会负责清理和回收这些资源。
6.4 Hello的execve过程
调用fork()创建子进程后,子进程会调用execve()来加载并执行新程序hello。execve()会替换当前进程的内存映像,包括代码、数据和堆栈,以加载hello程序。该函数不返回给原程序,而是直接在新程序的上下文中开始执行。新程序的代码和数据段被加载到内存,并且必要的资源(如打开的文件)被映射到进程地址空间。加载完成后,控制权转移到新程序的入口点(通常是_start),然后调用__libc_start_main来初始化C库并调用hello程序中的main函数。这样,子进程就成功加载并运行了新程序hello。
6.5 Hello的进程执行
当Shell执行fork为hello创建一个子进程时,这个子进程拥有独立的逻辑控制流。在hello运行过程中,若未被抢占,则正常执行;若被操作系统抢占,会进行上下文切换,暂停hello执行,转而调度其他进程。
当hello调用sleep函数时,为最大化处理器资源利用,sleep会请求内核将hello挂起,进行上下文切换,让其他进程运行。此时,hello被移入等待队列并开始计时。计时结束后,中断触发,hello被重新调度,从等待队列移出,恢复执行其逻辑控制流。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
1.在程序正常运行时,一共打印8次提示信息,乱按加回车对程序不产生影响,回收进程。
2.按下Ctrl + C,进程收到SIGINT信号,结束并回收hello进程。
3.按下Ctrl + Z,进程收到SIGSTP信号,shell显示提示信息并挂起hello进程。
4.ps jobs可查看hello进程的挂起
- pstree可查看所有进程的树状数
- kill可杀死进程
- 输入fg %1命令将hello进程再次调到前台执行,shell显示进程continue,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。
6.7本章小结
本章概述了进程概念、Shell-bash基础,并通过hello程序实例分析了fork和execve函数的运行机制,以及带参执行时的异常和信号处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
- 逻辑地址:
hello程序中的变量和函数位置通过段选择符和偏移量组成的逻辑地址来标识。 - 2.线性地址(或虚拟地址):
逻辑地址转换后得到线性地址,代表hello程序在连续地址空间中的位置。
3.虚拟地址:
与线性地址相同,是程序在逻辑上使用的地址,通过操作系统和硬件映射到物理内存。
4.物理地址:
物理地址是RAM中实际存储数据的地址,通过内存管理单元将虚拟地址转换为物理地址来访问。
7.2 Intel逻辑地址到线性地址的变换-段式管理
(以下格式自行编排,编辑时删除)
逻辑地址由段选择符和段内偏移量构成。段选择符是16位字段,其中前13位为索引号,后3位包含硬件相关细节。这样,逻辑地址就能准确地定位到内存中的特定位置。
|
如上图。
Intel处理器通过段式管理实现从逻辑地址到线性地址的转换。每个程序都维护一个段表,记录了程序各段在内存中的状态信息,如段号、起始地址、长度等。段寄存器中存储的段选择符用于查找段表,从而确定段的起始地址和访问权限。段选择符是一个16位的值,包括索引和表标识符,它指向全局或局部描述符表中的一个段描述符。通过索引,可以定位到段描述符,并从中获取段基址。结合偏移量,段基址形成线性地址,也称为虚拟地址。这样,处理器就能根据逻辑地址定位到物理内存中的实际位置。
7.3 Hello的线性地址到物理地址的变换-页式管理
(以下格式自行编排,编辑时删除)
CPU中的页表基址寄存器(PTBR)指向当前页表。虚拟地址分为虚拟页号(VPN)和虚拟页面偏移(VPO)。MMU使用VPN来选择页表项(PTE),将PTE中的物理页号(PPN)与VPO组合,形成物理地址。由于物理和虚拟页面的大小相同,物理页面偏移(PPO)与VPO相同。
PTE有效则页命中,直接取物理页号PPN与PPO组合成物理地址。PTE无效则发生缺页,由内核处理,选择牺牲页并加载新页。之后返回原进程,重新执行缺页指令,此时页命中,组合PPN与PPO得物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
(以下格式自行编排,编辑时删除)
TLB是一个虚拟寻址的小缓存,每行存储一个PTE块。TLB通常具有高相联度,通过VPN的位来选择组和匹配行。若TLB有T=2^t个组,TLB索引(TLBI)来自VPN的最低t位,而TLB标记(TLBT)则由VPN的其余位构成。这样,TLB能快速查找PTE以加速地址转换。
VPN的36位被分为四个9位的部分,每个部分作为不同层级页表的偏移量。CR3寄存器存储着第一级页表(L1)的物理地址。VPN的第一个9位用于定位L1页表中的一个PTE,该PTE包含第二级页表(L2)的基地址。VPN的第二个9位则用于定位L2页表中的一个PTE,以此类推。最终,在第四级页表中,PTE包含一个40位的物理页号(PPN),它与虚拟页面偏移量(PPO)结合,共同形成物理地址.
7.5 三级Cache支持下的物理内存访问
缓存(Cache)的访问过程相对直接,需要物理地址被分割为标记(Tag)、组索引(Set Index)和块偏移(Block Offset)三个部分。首先,我们使用组索引来确定地址在缓存中对应的组。随后,通过比较缓存中该组的标记和有效位,我们判断所需数据是否已缓存。
如果数据命中缓存(Cache Hit),则我们可以直接利用块偏移来读取目标数据。然而,如果数据未命中缓存(Cache Miss),则我们需要从下一级缓存(或主存)中查找该数据。请注意,下一级缓存并不总是传统意义上的缓存,例如对于L3缓存来说,其下一级通常是主存储器。
以Core i7 CPU的一级缓存(L1 Cache)为例,其大小为32KB,每组包含八路(8-way set associative),每个缓存块大小为64字节。经过计算,我们知道这个缓存共有64个组。由于Core i7 CPU使用52位物理地址,我们可以根据这个信息对物理地址进行划分,如图所示。这样,我们就可以有效地管理和利用缓存,从而提高数据访问的速度和效率。
经过内存管理单元(MMU)的转换,虚拟地址被映射为物理地址。计算机随后使用物理地址中的组索引在L1缓存中查找对应的缓存组。在找到缓存组后,计算机通过比较标记位来确认数据是否存在于该组中。如果标记位匹配成功,并且有效位指示该缓存块有效(值为1),则计算机使用块偏移量从缓存块中提取数据,并将其返回给CPU。
如果标记位不匹配或有效位指示缓存块无效(未命中),计算机则需要从下一级缓存(如L2)中重复上述搜索过程。如果数据在下一级缓存中命中,它会被加载到L1缓存中,以便未来的快速访问。
当需要将数据写回L1缓存时,如果L1中没有空闲的缓存块(即没有有效位为0的块),则必须选择一个缓存块进行替换。为了确定哪个缓存块最不可能在近期被再次访问,计算机通常采用最近最少使用(LRU)算法来选择一个缓存块进行替换(牺牲)。
对于L2和L3缓存的访问过程与L1缓存相同,都是利用物理地址的组索引、标记位和块偏移量来定位数据。这种层次化的缓存结构有助于优化数据访问性能,提高系统整体效率。
7.6 hello进程fork时的内存映射
当fork函数被hello进程调用时,内核会为新进程(子进程)hello(通常是父进程的副本)初始化一系列必要的数据结构,并赋予其一个独特的进程标识符(PID)。为了构建子进程的虚拟内存空间,内核会复制父进程的mm_struct(内存管理结构)、区域结构(通常指vm_area_struct,用于描述进程的虚拟内存区域)以及页表(Page Table)的当前状态。
在复制过程中,为了确保两个进程(父进程和子进程)之间的内存隔离,内核会将这两个进程中的每个页面都设置为只读,并将每个区域结构标记为私有的写时复制(Copy-On-Write, COW)。
当fork在子进程中返回时,子进程的虚拟内存布局与调用fork时父进程的虚拟 内存布局完全相同,但所有的页面都被设置为只读和写时复制。这意味着,在任一进程(父或子)尝试写入某个页面之前,这两个进程共享相同的物理内存页面。
然而,一旦任一进程尝试写入一个标记为写时复制的页面,内核就会触发写时复制机制,为该进程分配一个新的物理页面,并将写入操作重定向到这个新页面上。这样,原始的物理页面保持不变,而每个进程则拥有了自己私有的、可写的页面副本。通过这种方式,内核为每个进程维护了独立的地址空间,同时优化了内存使用,因为只有当页面实际被写入时,才会发生物理内存的复制。
7.7 hello进程execve时的内存映射
execve函数加载并运行hello程序时,确实需要经过一系列步骤来设置新的进程映像。以下是这些步骤的重新表述,确保意思不变:
清除现有用户空间
在加载新程序之前,execve会移除当前进程hello虚拟地址空间中用户部分的所有已存在区域(或称为段)。这些区域可能包含先前程序加载的代码、数据和其他资源。
创建新的私有区域
为了加载hello程序,execve会创建新的、私有的、写时复制(Copy-On-Write, COW)的区域结构来存放新程序的代码、数据、未初始化数据(bss段)以及栈。其中:
代码区域通常对应于hello程序文件中的.text部分,它包含程序的机器指令。
数据区域通常对应于.data部分,包含程序初始化时需要的静态数据。
bss区域是未初始化的数据段,它的大小在hello程序的元数据中有定义,但实际内存空间会被分配为全零。
栈区域用于程序执行时的函数调用和局部变量存储,初始时大小可能为零,但会随着程序运行而增长。
映射共享库
如果hello程序依赖于共享对象或目标文件(如标准C库
libc.so),execve会负责将这些共享库动态链接到hello程序,并将它们映射到用户虚拟地址空间中的共享区域。这样,多个进程可以共享相同的库代码和数据,从而节省内存。
设置程序执行起点
最后,execve会更新当前进程上下文的程序计数器(Program Counter, PC),使其指向新程序代码区域的入口点。这通常是hello程序文件.text部分的开始处,即程序的入口函数(如main函数)的地址。一旦程序计数器被设置,当进程恢复执行时,它将开始执行新加载的hello程序。
7.8 缺页故障与缺页中断处理
当内存管理单元(MMU)在尝试翻译某个虚拟地址A时遇到缺页异常,这个异常会触发控制权的转移至内核的缺页处理程序。处理程序会执行以下步骤来应对这一事件:
1.虚拟地址的合法性:
缺页处理程序会遍历区域结构(通常称为段或虚拟内存区域)的链表,将虚拟地址A与每个区域结构的起始和结束地址进行比较。如果A不属于任何区域结构,即没有匹配到任何区域,那么地址A被视为非法,此时会触发一个段错误(segmentation fault),并可能终止进程。
2.检查内存访问的权限:
如果虚拟地址A是合法的,但试图进行的内存访问(如写操作到一个只读区域)是不允许的,那么缺页处理程序会触发一个保护异常(protection fault)。这通常会导致进程被终止,因为它违反了内存保护规则。
3.处理缺页:
如果缺页异常是对合法地址进行合法访问时出现的,缺页处理程序就会开始处理缺页。这通常包括以下步骤:
选择牺牲页面:内核会选择一个页面来替换(或称为“牺牲”),以便为新页面腾出空间。这个页面可能是最近最少使用(LRU)的页面,或者是根据其他页面替换算法选择的。
交换页面(如果需要):如果选中的牺牲页面已被修改(即它是“脏的”),内核需要将它写回到磁盘(即“交换出去”或“页出”),以便保存它的内容。
分配新页面:一旦牺牲页面被处理(如果需要),内核会为新页面分配物理内存,并更新页表以反映这一变化。
更新页表:最后,内核会更新页表条目,以确保虚拟地址A现在映射到正确的物理地址。
4.重启指令:
当缺页处理程序完成所有必要的操作并返回时,CPU会重新启动引起缺页的指令。这次,由于页表已经更新,MMU能够正常地翻译虚拟地址A,并允许指令继续执行。
这个过程确保了当进程尝试访问尚未加载到物理内存中的页面时,系统能够透明地处理这种情况,而无需用户干预或中断进程的正常执行。
7.9动态存储分配管理
动态内存分配器负责管理一个进程的虚拟内存区域(通常称为“堆”),它将这些内存视为由不同大小的连续虚拟内存块组成的集合。这些块要么已经被分配给应用程序使用(称为“已分配”块),要么还未被分配且可供将来使用(称为“空闲”块)。
内存分配器有两种主要风格:
1.显式分配器:在这种风格中,应用程序需要明确地请求和释放内存块。C标准库提供了一个名为malloc的函数包来实现这种显式分配器。当C程序需要内存时,它会调用malloc函数来分配一个块;而当不再需要这个块时,它会调用free函数来释放它。在操作系统层面,内核通过调用sbrk(或其他类似函数)来扩展或收缩堆的大小,以满足应用程序的内存请求。
2.隐式分配器(或垃圾收集器):与显式分配器不同,隐式分配器会自动检测何时一个已分配的内存块不再被程序使用,并自动释放它。这种分配器通常被称为“垃圾收集器”,因为它们会“收集”那些不再需要的内存块,并将其返回给系统或用于其他目的。垃圾收集器通过跟踪内存的使用情况并检测何时内存块不再被引用来实现这一功能。
简而言之,显式分配器要求程序员手动管理内存的生命周期,而隐式分配器则自动管理内存,减轻了程序员的负担。
7.10本章小结
1.hello程序的存储器地址空间:描述了hello程序在内存中的布局,包括代码段、数据段、堆和栈等。
2.Intel的段式管理:介绍了Intel处理器如何使用段式内存管理来组织和保护内存。
3.hello的页式管理:详细说明了操作系统如何使用页式内存管理来分配和管理hello程序的内存空间。
4.VA到PA的变换:阐述了如何将虚拟地址(VA)转换为物理地址(PA),以便CPU能够访问实际的物理内存。
5.物理内存访问:解释了如何通过物理地址直接访问内存中的数据。
6.hello进程fork、execve时的内存映射:分析了fork和execve系统调用如何影响hello进程的内存映射和布局。
7.缺页故障与缺页中断处理:探讨了当CPU试图访问一个尚未加载到物理内存中的页面时,如何触发缺页故障和中断,以及操作系统如何处理这些故障。
8.动态存储分配管理:讨论了如何在程序运行时动态地分配和释放内存,以满足程序的动态内存需求。(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
在Linux系统中,IO设备被抽象化为文件的概念,这种设计使得所有的输入和输出操作都可以被看作是对特定文件的读取和写入。通过将设备映射为文件,Linux内核为应用程序提供了一个简洁、底层的接口,通常被称为Unix I/O。这种设计使得无论是文件、磁盘、网络套接字还是其他类型的IO设备,都可以使用相同的、一致的方式来处理:打开设备文件、调整文件指针位置(如果需要的话)、读取或写入数据、最后关闭文件。这样的抽象极大地简化了IO操作的管理,并为开发者提供了统一且易于理解的编程接口。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O 接口概述:
文件开启。应用程序通过请求内核开启特定文件来表明其意图访问某个I/O设备。内核随后返回一个非负整数作为文件描述符,用于后续对该文件的所有操作。内核负责记录与该打开文件相关的所有信息,而应用程序只需记住这个描述符。
初始文件。Linux shell创建的每个进程开始时默认拥有三个已打开的文件:标准输入(文件描述符为0)、标准输出(文件描述符为1)和标准错误(文件描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,这些常量可用作文件描述符的替代值。
文件位置调整。对于每个已打开的文件,内核维护一个文件位置k,初始值为0。这个位置表示从文件开头起的字节偏移量。应用程序可以通过执行seek操作来显式地设置文件的当前位置为k。
数据读写。读操作意味着从当前文件位置k开始,将文件中的n>0个字节复制到内存中,并将k增加n。当尝试读取一个大小为m字节的文件,且k≥m时,会触发一个称为“文件结束”(EOF)的条件,应用程序能够检测到这一条件。值得注意的是,文件末尾并没有明确的“EOF符号”。类似地,写操作则是从内存中将n>0个字节复制到文件中,从当前位置k开始,并更新k的值。
文件关闭。当应用程序完成对文件的访问后,它会通知内核关闭该文件。作为响应,内核会释放文件打开时创建的数据结构,并将该描述符释放回可用的描述符池中。无论进程因何原因终止,内核都会确保所有打开的文件被关闭并释放相关资源。
8.2.2 Unix I/O 函数详解:
int open(char* filename, int flags, mode_t mode)
进程通过调用open函数来打开已存在的文件或创建新文件。open函数将filename转换为文件描述符,并返回该描述符的数值。返回的描述符通常是当前进程中尚未使用的最小描述符。flags参数指定了进程对文件的访问方式,而mode参数则设置了新文件的访问权限。
int close(int fd)
close函数用于关闭指定的文件描述符fd,并返回操作结果。
ssize_t read(int fd, void *buf, size_t n)
read函数从文件描述符fd指向的文件的当前位置读取最多n个字节的数据,并将这些数据存储到内存地址buf指向的位置。如果发生错误,函数返回-1;如果达到文件末尾(EOF),则返回0;否则返回实际读取的字节数。
ssize_t write(int fd, const void *buf, size_t n)
write函数将内存地址buf指向的最多n个字节的数据写入到文件描述符fd指向的文件的当前位置。函数返回实际写入的字节数,如果发生错误则返回-1。
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
printf函数的函数体如下:
在描述可变参数函数和字符显示流程时,以下是您所描述内容的另一种表达方式:
函数在处理可变参数列表时,使用了...来表示参数的数量和类型是不确定的。va_list实际上是一个通过typedef定义的类型,它通常被实现为指向char的指针。在函数中,为了访问这些可变参数,通常会使用宏如va_start、va_arg和va_end来初始化和遍历参数列表。
在您给出的例子中,va_list arg = (va_list)((char*)(&fmt) + 4); 这句代码(尽管不是标准做法,但为了说明)试图跳过某个固定大小的参数(假设是fmt指针),以获取可变参数列表中的第一个参数。然而,正确的做法是使用va_start宏来初始化arg,并通过va_arg宏来获取每个参数。
接着,程序调用vsprintf函数,该函数根据提供的格式字符串(和通过可变参数列表传递的其他参数)来格式化字符串,并将结果存放在buf缓冲区中。vsprintf返回写入的字符数量i。
然后,程序使用系统调用write(通常是通过系统调用接口如syscall或int 0x80在x86体系结构中)将buf缓冲区中的前i个字符发送到标准输出(通常是终端或控制台)。
在底层,字符显示涉及到一个字符显示驱动子程序。这个驱动子程序负责将ASCII字符从用户空间的应用程序转换为图形界面可以理解的字模(bitmap)或字形(font),并将这些信息存储在视频RAM(VRAM)中。VRAM存储了每个像素点的颜色信息(如RGB分量)。
显示芯片根据预先设定的刷新频率,逐行从VRAM中读取像素数据,并通过信号线将这些数据发送到液晶显示器。液晶显示器根据接收到的RGB分量来显示每个像素点,从而呈现出字符和图像。
最终,程序返回实际写入的字符数量i,这个值通常用于确认或进一步处理输出。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
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;
}
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar调用系统函数read,发送一个中断信号,内核抢占这个进程,用户输入字符串,键入回车后(字符串和回车都保存在缓冲区内),再次发送信号,内核重新调度这个进程,getchar从缓冲区读入字符。
8.5本章小结
本章介绍了Linux系统如何管理IO设备,包括其接口和函数。同时,对printf和getchar函数的底层实现有了基本了解,这些函数依赖于Linux的IO设备管理机制,如通过write和read系统调用来实现数据的读写。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello程序的一生经历了如下过程:
1. 预处理
将hello.c中include的所有外部的头文件头文件内容直接插入程序文本中,完成字符串的替换,得到hello.i,方便后续处理;
2. 编译
通过词法分析和语法分析,将合法指令翻译成等价汇编代码。通过编译过程,编译器将hello.i翻译成汇编语言文件hello.s;
3. 汇编
将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o目标文件中;
4. 链接
通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello;
5. 加载运行
打开shell,在其中输入./hello 2022113557 宫名扬 1,终端为其fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行;
6. 执行指令
在该进程被调度时,CPU为hello其分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器逐步更新,CPU按照控制逻辑流执行指令;
7. 访存
内存管理单元MMU将逻辑地址,一步步映射成物理地址,进而通过三级高速缓存系统访问物理内存中的数据;
8. 动态申请内存
printf会调用malloc向动态内存分配器申请堆中的内存;
9. 信号处理
进程时刻等待着信号,如果运行途中键入ctrl-c ctrl-z则调用shell的信号处理函数分别进行停止、挂起等操作,对于其他信号也有相应的操作;
10. 终止并被回收
shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 描述 |
hello.i | 预处理后得到的ASCII码的中间文件 |
hello.s | 编译后得到的ASCII汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello1.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
(参考文献0分,缺失 -1分)
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
(参考文献0分,缺失 -1分)