计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 物联网工程
学 号 xxxxxxxxxx
班 级 xxxxxxx
学 生 xxx
指 导 教 师 吴锐
计算机科学与技术学院
2024年5月
"Hello"程序的一生经历了多个阶段,从预处理、编译、汇编、链接到进程管理、存储管理和I/O管理。在这些过程中,操作系统、壳和硬件为其提供了必要的支持和保障,使其能够在计算机系统中完整地运行。本文详细探讨了在Linux环境下,hello.c文件从编写到最终执行的整个生命周期。结合学习到的知识,逐步对比和解析各个过程在Linux中的实现机制及其原因,并深入研究了hello.c文件的P2P和020的具体实现过程。
关键词:P2P;预处理;编译;汇编;链接;进程管理;异常;
目 录
第1章 概述
1.1 Hello简介
Hello.c经过预处理器得到hello.i文件,然后经过编译得到hello.s,然后经过汇编得到hello.o,最后链接生成可执行文件hello(.out)。在shell中,fork产生一个子进程,就完成了从program到process。其编译执行的过程可分为P2P和020两个部分:
1.1.1 P2P(From Program to Process)
此过程指的是将C语言程序文件 hello.c 转换为可执行进程的过程。在 Linux 系统中,这个过程经历了如下几个步骤:
- 预处理(Preprocessing):使用C预处理器cpp对hello.c进行预处理,包括宏展开和头文件包含,生成hello.i。
- 编 译(Compiling):C编译器ccl(C Compiler)对hello.i进行词法分析、语法分析和优化等操作,生成汇编代码hello.s。
- 汇 编(Assembling):汇编器as(Assembler)将hello.s翻译成机器语言指令,生成一个目标文件hello.o。
- 链 接(Linking):链接器ld(Linker) 将hello.o和其他依赖的库文件进行链接,生成一个可执行文件hello。
- 运 行(Running):在shell内输入命令./hello,shell会通过系统调用fork()为其创建一个子进程,并在子进程当中执行hello。
1.1.2 020(From Zero-0 to Zero-0)
020是指将一个可执行文件hello.out载入内存并运行的过程。此过程中,0表示内存中没有hello文件的状态。具体而言,020在Linux下包含以下过程:
- 内存载入(Memory Loading):程序开始执行时,子进程调用execve函数将hello.out文件载入内存,并使用mmap函数将其映射到合适的内存位置。
- 进程控制(Process Control):内核中的进程控制器为hello进程分配时间片,使其开始执行自身的逻辑控制流。
- 进程回收(Process Reclamation):程序执行结束后,父进程会回收hello进程,并在内核中删除相关数据,使内存恢复到没有hello文件的初始状态。
1.2 环境与工具
- 硬件环境:X64 CPU;2.30GHz;16G RAM;1.5THD disk
- 软件环境:Windows11 64位;Vmware Workstation 17 Pro;Ubuntu 22.10
- 开发与调试工具:Visual Studio 2019 64位;CodeBlocks 64位;vim+gcc; readelf; objdump;ldd;EDB等
1.3 中间结果
文件名称 | 功能 |
hello.i | hello.c预处理后生成的中间文件 |
hello.s | hello.i编译后生成的汇编文件 |
hello.o | hello.s汇编后生成的可重定位目标文件 |
hello | hello.o和其他库文件经链接后生成的可执行文件 |
hello_o.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.elf | 由hello可执行文件生成的ELF格式文件 |
表1 中间文件名称及功能
1.4 本章小结
以上过程详细描述了从C语言程序文件 hello.c 到可执行进程 hello 的转换过程以及相关的环境、工具和中间结果。通过预处理、编译、汇编、链接和运行,将源代码转化为可在Linux系统中执行的进程。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理是编译过程中的第一个阶段,它通过预处理器对源代码进行处理,生成一个经过预处理的中间文件。
2.1.1 预处理的概念
预处理(Preprocessing)是计算机科学中编译器的一种重要处理阶段,用于在实际编译之前对源代码进行预处理和转换,主要目的是为了简化编程工作,提高代码的复用性和可维护性。此过程并不包括对源代码内容的解析,只是进行一些简单的插入、删除和替换等文本操作。
2.1.2 预处理的作用
预处理器(C Pre-Processor)的主要功能是在编译过程之前对源代码进行处理,将源代码中的宏定义、条件编译指令、包含其他文件的指令等预处理指令处理完毕后生成新的源代码文件,以便编译器对其进行编译。预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
2.2在Ubuntu下预处理的命令
预处理的命令:gcc -E hello.c -o hello.i
图1 hello.c预处理
2.3 Hello的预处理结果解析
使用vim打开hello.i ,观察发现文件变成了3601行,原来的main函数保留在文件最后,对于define预处理,则检查程序中每一次出现的位置,做宏定义替换。而剩余部分则是对stdio.h、unistd.h、stdlib.h等头文件的包含展开,同时删除了所有注释,使代码更加完整而不冗余。
图2 hello.i部分代码
2.4 本章小结
本章主要介绍了预处理的概念及作用、gcc下的预处理指令,此外还对Hello的预处理结果进行解析,明确了预处理在整个P2P过程中的重要性。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
得到hello.i后,我们需要对其进行编译,得到hello.s汇编文件。
编译(Compilation)是将高级编程语言(如C、C++等)的源代码转换成汇编语言(*.s)的过程。在编译的过程中,源代码经过词法分析、语法分析、语义分析等步骤,转换成对应的中间表示形式,即汇编代码。编译的主要目的是提高程序的执行效率,使程序更加稳定和安全。
编译器是完成编译过程的程序,它将高级程序源代码作为输入,通过语法分析、语义分析、优化和代码生成等多个阶段,根据源代码的语法、数据类型、函数定义等信息,对源代码进行检查、转换和优化,生成对应的汇编代码文件。
3.1.2 编译的作用
- 代码转换:编译的主要作用是将源代码(用高级语言编写的程序)转换成目标代码(机器语言或低级语言),从而使计算机能够理解和执行。
- 错误检查:编译过程会检查源代码中的语法错误,包括拼写错误、语法结构错误等,并在编译时指出这些错误,以便开发者进行修正。
- 优化:编译器可以对源代码进行优化,以提高生成的目标代码的执行效率。优化可能包括减少不必要的计算、消除冗余代码、重新组织指令顺序等。
- 生成可执行文件:编译成功后,会生成一个可执行文件,该文件包含了计算机可以执行的指令。用户可以直接运行这个可执行文件,而不需要每次执行时都重新编译源代码。
- 提高安全性:编译过程可以帮助提高软件的安全性。由于源代码是经过编译转化为目标代码的,因此可以防止未经授权的用户直接查看或修改源代码,从而在一定程度上保护了软件的保密性和完整性。
3.2 在Ubuntu下编译的命令
编译的命令:gcc -S hello.i -o hello.s
图3 hello.i编译
3.3 Hello的编译结果解析
hello.i编译得到hello.s文件,以下将对hello.s使用的伪指令、编译过程中对各个数据类型的处理以及各类操作进行分析。
3.3.1 hello.s伪指令
内容 | 含义 |
.file | 源文件声明 |
.text | 代码段 |
.section | 指定接下来的指令和数据所属的段 |
.rodata | 只读代码段 |
.align | 指令或者数据的存放地址的对齐方式 |
.global | 声明全局符号 |
.data | 存放已经初始化的全局和静态变量 |
.type | 声明符号是数据/函数类型 |
.long/.string | 声明数据类型long/string |
表2 hello.s伪指令及其含义
此处查看伪指令部分,.file指明文件名是hello.c,.text指示代码段,.section指示rodata段,.align8指明对齐方式。
图4 伪指令
3.3.2 hello.s数据
发生改变v去啊hello.s中包含常量和变量两种类型。
常量:字符串常量(存储在.rodata节)和整数常量(立即数形式)
图5、6 hello.s常量数据
变量:一般来说,过程通过减小%rsp的值为局部变量申请空间。汇编你代码中,%rsp被一次性减32,根据代码的上下文可知,从地址R[rrbp]-4到地址R[rrbp]的这段4Byte空间被用来存放int局部变量i。由此可知在本段汇编代码中,通过基于%rbp计算有效地址的方式实现对int类型的局部的引用。
int类型参数argc通过寄存器%edi传入main函数,之后movl将其拷贝到栈帧的局部变量区。代码中还有一些整型以立即数的形式出现,这些立即数记录在代码区。
图7 汇编代码引用i的指令
3.3.3 hello.s数据操作
hello.s中包含赋值、比较、加法以及数组操作四种数据操作。
- 赋值:mov指令(对循环变量i赋初值0)
- 比较:cmp指令(对argc&4、i&8的数值进行比较)
- 加法: add指令(对循环变量进行累加i++)
- 数组操作:movq指令(输出时以及调用atoi函数时将argv[ ]内数据传出)
图8 对argv下标索引相关汇编指令的解析
3.3.4 hello.s控制转移
该代码涉及分支结构和for循环结构。C语言中的分支结构依靠if、else和switch等语句来实现。在本代码中,使用了if语句。在汇编代码层面,if语句通常通过cmp指令和条件跳转指令配合完成。
如图9所示,当执行cmpl指令时,如果-20(%rbp)的值等于4,ZF(零标志)会被设置为1,否则ZF会被重置为0。当执行je指令时,如果ZF的值为1,程序将跳转到.L2处的代码;如果ZF的值为0,程序将不会跳转,继续执行下一条指令。
图9 分支结构的汇编代码
C语言的for循环结构的实现也离不开跳转指令,也离不开关系操作。如图 10所示,这个for循环首先由一个无条件跳转,在刚开始for循环时跳转到条件判断处.L3,然后如果满足条件,就跳转到.L4。当循环体的代码执行完之后,又顺序执行到条件跳转。
图10 for循环结构的汇编代码
3.3.5 hello.s函数操作
hello.s中包含参数传递、函数调用、函数返回等函数操作。
- 控制传递:要执行过程Q,就要在开始调用Q后将程序计数器PC设置为Q的代码的起始地址;过程Q结束之后,要把控制权转移给过程P,于是在返回后,要把程序计数器设置为P中调用Q的指令的下一条指令的地址。
- 传递数据:P要能够向Q提供0个、一个或多个参数,Q通常会给P返回一个值(通常通过%rax/%eax/%ax%al寄存器返回)。
- 分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
程序中涉及的函数操作列举如下(x86-64系统):
- main函数:
· 控制传递:系统启动函数__libc_start_main使用call指令调用main函数。call指令将下一条指令的地址压入栈中,然后将%rip寄存器的值设置为main函数的起始地址。
· 数据传递:__libc_start_main向main函数传递参数argc和argv,分别存储在%edi(argc,类型为int)和%rsi(argv)寄存器中。main函数的return 0对应汇编中的三条指令:将%eax设置为0,然后执行ret(中间的leave指令稍后分析)。ret指令从栈中弹出返回地址并赋值给%rip。
· 内存分配与释放:%rbp寄存器记录对应栈帧的最高地址减8的值。通过减小%rsp的值在栈中分配空间,程序结束时调用leave指令。leave指令将%rbp的值赋给%rsp(释放局部变量占用的空间),然后从栈中弹出一个4字节长的值给%rbp(实际上是__libc_start_main函数的%rbp值),恢复栈空间到调用main函数之前的状态。
- printf函数:
· 数据传递:第一次调用printf时,将%rdi寄存器设置为字符串"用法: Hello 学号 姓名 电话号码 秒数!\n"的首地址。第二次调用printf时,%rdi寄存器设置为字符串"Hello %s %s\n"的首地址,%rsi寄存器设置为argv[1],%rdx寄存器设置为argv[2]。
· 控制传递:第一次调用printf只有一个字符串参数,所以使用call puts@PLT;第二次调用printf使用call printf@PLT。
- exit函数:
传递数据:将%edi设置为1。
控制传递:call exit@PLT。
- atoi函数
传递数据:将%rdi设置为argv[3]。
控制传递:call atoi@PLT。
- sleep函数:
传递数据:将%edi设置为&eax(即atoi函数返回的值)。
控制传递:call sleep@PLT。
- getchar函数:
控制传递:call gethcar@PLT
3.4 本章小结
本章介绍了编译的概念和作用,并着重分析了编译生成的汇编代码。
汇编代码是低级语言,机械难懂,但是它是很多高级语言的基础。它更接近于CPU,这使得它的代码的可移植性差,但也使得它可以提供更多操作CPU的方法(C语言只是使用了CPU的指令集的一个子集)。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编文件hello.s已经具备在指令级别上控制CPU进行数据传递等操作,但仍需要将汇编语言汇编生成机器语言hello.o来实现程序的执行。
4.1.1 汇编的概念
汇编是将编译后的汇编语言程序(.s)翻译成机器可识别和执行的机器指令,并将这些指令打包成一种叫做可重定位目标程序,进而转换成机器语言程序(.o)的过程。它是编译器生成可执行文件的一个重要步骤。
汇编程序使用一些特殊的指令,这些指令直接对应底层的硬件架构,因此生成的机器语言程序可以直接在计算机上运行。
4.1.2 汇编的作用
汇编能够将人类可读的汇编代码转化为机器可执行的指令,这些指令可以被计算机处理器直接识别和执行。通过汇编,程序员可以直接控制计算机底层的操作,实现高效的程序代码。
因此,汇编在操作系统、嵌入式系统、驱动程序等领域有着广泛的应用。同时,汇编也是高级语言编译器、解释器等软件工具的重要基础,因为这些工具需要将高级语言翻译成汇编代码后再进行处理。
注:这里的汇编是指从.s到.o即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
汇编的指令:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
图11 汇编命令
4.3 可重定位目标elf格式
首先输入命令readelf -a hello.o > hello_o.elf,获得hello.o的ELF格式。观察发现hello.elf文件包含ELF头(ELF Header)、节头(Section Header)、符号表、重定位节等部分。
4.3.1 ELF头
ELF头部从一个16字节的magic序列开始,描述了生成文件的系统的字大小和字节顺序,前4字节7f 45 4c 46分别对应删除(Del) E L F的ASCII码。操作系统在加载可执行文件时会验证magic序列的正确性。 Header的其余部分包含帮助链接器进行语法分析和解释目标文件的信息,包括数据类型和字节顺序、操作系统/ABI、目标文件类型(如REL可重定位、EXEC可执行或共享的)、机器类型(如x86-64)、节头部表的文件偏移量、规模以及条目的大小和数量等相关信息。
4.3.2 节头
节头列表包含文件中各节的名称、类型、地址、偏移量、大小、旗标、链接和对齐信息等内容,在hello.elf文件中共包含13个节。
4.3.3 符号表
符号表存放程序中定义和引用的函数和全局变量的相关信息,包括相对于目标节的起始位置偏移Value、大小Size、类型Type/Bind以及符号名称Name。由于在此还没有进行相关库函数的链接,因而Value都为0。
4.3.4 重定位节
此部分包含main.o中需要重定位的信息,当链接器把目标文件和其他文件组合时,需要根据具体类型修改这些位置。每条重定位条目包含偏移量、信息、类型(标识对应的地址计算算法)、符号值、符号名称、加数等信息。下图.rela.text是hello.c中调用函数的重定位条目;.rela.eh_frame是对.text代码段的重定位条目。
4.4 Hello.o的结果解析
通过objdump -d -r hello.o反汇编hello.o,并与hello.s对照如下图所示。发现反汇编代码与hello.s基本相似,汇编后调用函数以及访问内存时还需链接器作用才能确定数据/函数地址,而hello.s与反汇编代码在处理这部分存在一些区别:
- 分支转移:在反汇编文件中,跳转目标使用的是PC相对的地址,即目标指令地址与当前指令下一条指令的地址之差的补码表示。而在.s文件中,通常使用段名称进行跳转。
- 数制表示:反汇编中通常使用十六进制数,而.s文件中使用的是十进制数。
- 函数调用:在反汇编中,call的目标地址是当前下一条指令的地址。而在.s文件中,函数调用之后直接跟着函数名称。
图12 hello.o反汇编与hello.s部分对照
4.5 本章小结
本章介绍汇编基本概念,通过分析hello.o文件和elf文件,研究了elf文件的结构。在比较反汇编文件和汇编文件的细节中,发现二者的一些区别。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可以被加载到内存中执行。链接可以在编译时、加载时或运行时进行。链接的主要作用包括:
- 组织程序:通过将各种代码和数据片段链接在一起,形成一个单一文件,便于程序的组织和管理。
- 提高效率:链接过程可以消除重复的代码和数据,减少内存占用,提高程序的执行效率。
- 实现模块化开发:通过链接,可以将程序分解为多个模块,每个模块可以独立开发、编译和测试,从而实现模块化开发,提高开发效率。
- 处理依赖关系:链接过程可以处理不同模块之间的依赖关系,确保程序在编译和运行时正确链接到所需的模块和库。
- 实现动态加载:通过动态链接,可以在程序运行时加载不同的模块或库,实现动态加载和卸载功能。
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生成hello程序的ELF格式文件,观察各段的基本信息,并与上文4.3节中由hello.o生成的hello_o.elf进行对比。
5.3.1 ELF头
ELF头与hello.elf的基本相同,不同之处在于文件类型由REG变为EXEC可执行目标文件,同时节头大小数量增加,并获得了入口地址。
图13 ELF头信息
5.3.2 节头
链接后,节头数量有所增加,但各条目内包含信息种类并未发生变化。
图14 ELF节头信息
5.3.3 符号表
链接后,符号表条目显著增加,包含各目标文件中符号定义及引用信息。
图15 ELF符号表信息
5.3.4 重定位节
链接后,重定位节内容变化为执行过程中需要通过动态链接调用的函数,同时类型也发生改变。
图16 ELF重定位节信息
5.3.5 其他内容
链接后,相较于hello_o.elf,hello.elf增加了程序头和段节。二者均是在链接过程中确定,其中程序头描述了系统准备程序执行所需的段和其他信息。
图17 hello.elf新增部分
5.4 hello的虚拟地址空间
使用edb加载hello,观察Data Dump部分,观察发现程序占有0x401000~ 0x402000的地址空间,通过5.3节中入口地址及各节的偏移量即可观察各节内容。
图18 edb虚拟内存窗口
5.5 链接的重定位过程分析
输入指令objdump -d -r hello,观察其中的的main函数,并与4.4节中hello.o的反汇编代码进行对比。
图19 反汇编对比(左为hello反汇编,右为hello.o反汇编)
首先,Hello.o.objdump文件中只有main函数,而Hello.objdump文件中不仅有main函数,还多了其他函数。这一变化与链接过程密切相关。
在使用ld命令进行链接时,指定了动态链接器为64位的/lib64/ld-linux-x86-64.so.2。crt1.o、crti.o、crtn.o主要定义了程序入口_start和初始化函数_init,_start程序调用Hello.c中的main函数。libc.so是动态库,其中定义了Hello.c中用到的printf、sleep、getchar、exit函数,以及_start中调用的__libc_csu_init、__libc_csu_fini、__libc_start_main。链接器将这些函数加入到目标文件中,使得Hello程序中多了好几个函数。这些函数的加入发生在符号解析过程中。
其次,Hello程序中的各个字节在运行时都有了虚拟地址,而Hello.o文件中只有节偏移信息。在符号解析之后,链接器对输入的目标模块的代码节和数据节进行重定位。使用objdump -d -r hello可以分析hello与hello.o的不同,说明链接过程。
结合hello.o的重定位条目,分析Hello程序中的重定位过程。静态库符号的重定位由链接器完成,动态库符号的重定位由动态链接器完成。这里先分析静态链接中的重定位过程。
以第一个常量字符串的重定位为例。通过重定位条目,链接器知道这个重定位的类型。虽然难以得到符号解析后重定位之前的重定位条目,但通过使用gdb、objdump和edb等工具,可以反推出第一个常量字符串的重定位条目,设为r。
- r.offset = 0x51
- r.addend = -4
- ADDR(.text) = 0x4010f0
- ADDR(r.symbol) = 0x402008(第一个常量字符串的首地址,并不是.rodata的首地址)
addr(.text)表示.text节在文件中的首地址。编译器会计算文件中要修改的首地址refptr = addr(.text) + r.offset;再计算运行时PC相对地址,然后将这个地址写入以refptr为首地址的连续四个字节:*refptr = ADDR(r.symbol) + r.addend – (ADDR(.text) + r.offset) = 0x402008 + (-0x4) - 0x401141 = 0xec3。观察发现确实是0xec3。
5.6 hello的执行流程
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
程序名称 | 程序地址 |
_start | 0x00000000004010f0 |
__lib_start_main(于libc.so.6) | 0x00007fbed3e29dc0 |
__cxa_atexit(于libc.so.6) | 0x00007fbed3e458c0 |
_init | 0x0000000000401000 |
main | 0x0000000000401125 |
puts@plt | 0x401090 |
exit@plt | 0x4010d0 |
getchar@plt | 0x4010b0 |
printf@plt | 0x4010a0 |
atoi@plt | 0x4010c0 |
sleep@plt | 0x4010d0 |
exit (于libc.so.6) | 0x00007ff9a7c455f0 |
表2 hello执行流程
5.7 Hello的动态链接分析
通过使用edb/gdb进行调试,可以分析hello程序在动态链接过程中的变化。在动态链接之前和之后,可以观察到程序中各个项目的内容变化。动态链接器在函数重定位时使用延迟绑定策略,将函数地址的绑定推迟到第一次调用该函数时才进行。以printf函数为例,分析其在动态链接初始化(dl_init)前后的GOT和PLT内容变化。在个人的Ubuntu环境中,PLT表的结构与教科书中有所不同,如图20所示。图中绿箭头指向的指令跳转到动态链接器,动态链接器根据栈中的参数修改GOT中相应条目的地址。这样,下一次跳转到蓝色高亮指令时,就会直接跳转到printf函数的实际地址。
图20 edb查看PLT
图21调用前.got
调用后,.got中的条目已经改变,说明动态链接完成。
图22 调用后.got
5.8 本章小结
本章讲述了链接的概念和多用,重点对静态链接和动态链接进行了分析,还理清了程序的加载过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
一个进程是一个执行中程序的实例。
进程简化了用户的内存操作的工作,提高了程序的通用性,是多个过程并发执行的基础,是计算机科学中最深刻,最成功的概念。
6.2 简述壳Shell-bash的作用与处理流程
壳(Shell)是一种命令解释器,是用户与操作系统之间的接口。如Windows下的命令行解释器,cmd、powershell,图形界面的资源管理器。Linux下的Terminal/tcsh、bash等等,也包括图形化的GNOME桌面环境。Shell是信号处理的代表,负责各进程创建与程序加载运行及前后台控制,作业调用,信号发送与管理等。Shell是人在操作系统中的代表。
Bash的处理流程大致可以分为以下几个步骤:
· 读取用户输入:读取用户输入的命令或脚本文件。
· 解析输入:将输入字符串切分,分析输入内容,解析命令和参数,并将命令行的参数转换为系统调用execve()所要求的形式。
· 判断命令类型:判断命令是否为内置命令。如果是,则立即执行;否则,调用fork()来创建子进程,自身调用wait()来等待子进程完成。在此期间,程序始终接受键盘输入信号,并对输入信号进行相应处理。
· 执行命令:子进程运行时,调用execve()函数,根据命令名查找可执行文件,将其加载到内存中并执行。
· 处理完成:子进程完成处理后,向父进程(即shell)报告。此时,终端进程被唤醒,完成必要的判别工作后,显示提示符,等待用户输入新命令。
6.3 Hello的fork进程创建过程
在命令行输入./hello命令运行hello程序时,由于该命令不是内置命令,Bash会通过调用fork函数创建一个新的子进程。fork函数执行过程中,操作系统会为新的子进程分配一个新的标识符(PID),然后在内核中分配一个进程控制块(PCB),并将其挂在PCB表上。接着,操作系统会将父进程的环境复制到子进程中,包括大部分PCB的内容,并为其分配资源,包括程序、数据、栈等。
新创建的子进程几乎与父进程相同,但并不完全相同。子进程会得到与父进程用户及虚拟地址空间相同的(但独立的)一份副本,包括代码段、数据段、堆、共享库以及用户栈。子进程还会获得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
6.4 Hello的execve过程
执行execve系统调用时,操作系统会首先清空当前子进程的用户空间栈,然后将待执行程序的命令行参数(argv)和环境变量(envp)压入栈中。随后,控制权转移到hello程序的入口点,即main函数。在这个过程中,execve还会将hello程序所需的库文件加载到内存中,并初始化程序所需的内存空间。如果execve成功执行,当前进程的代码和数据将被新程序内容完全替换,新的程序作为新的进程映像开始运行。如果执行过程中出现错误,比如找不到指定程序,execve会返回负值,表示执行失败,此时原有的进程将继续运行。
6.5 Hello的进程执行
hello进程在操作系统中的执行是由进程调度器对进程进行时间片调度,并通过上下文切换实现进程的执行。在执行过程中,操作系统合理调度,根据需要在用户态和核心态之间进行切换,并在进程结束后清除其资源。
6.5.1 进程调度的概念
进程调度是操作系统管理进程并分配处理器资源的过程。在进程执行期间,处理器会按照一定的时间片轮流使用各个进程。
在进程执行过程中,操作系统可以随时决定抢占当前正在执行的进程,并开始执行另一个进程。这种决策通常基于进程的优先级、等待时间、资源使用情况等因素。被抢占的进程的上下文信息,如寄存器状态、程序计数器、用户栈和内核栈等,会被保存到内核中,以便在需要时能够恢复该进程的原始状态。
内核通过上下文切换机制来实现进程的抢占和切换。在上下文切换过程中,当前进程的上下文信息会被保存到内核中,然后加载下一个进程的上下文信息,并将控制权转移到新进程的主函数中。这个过程确保了多个进程能够在处理器上有效地共享时间,从而实现并发执行。
6.5.2 用户态与核心态
操作系统中存在两种特权级别:用户模式和内核模式。用户模式下,进程只能访问自己的地址空间,不允许直接访问内核区的代码和数据;而内核模式下,进程可以执行指令集中的任何命令,并访问系统中的任何内存位置。这样的划分保证了系统的安全性,防止用户程序直接访问内核数据结构或系统硬件资源
当操作系统决定运行hello进程时,将在进程调度器中保存当前执行进程的上下文信息,并将控制权转移到hello进程的上下文。此时,CPU进入用户态。
当hello进程需要执行需要特权级别的操作(如I/O操作),会导致CPU进入核心态,此时操作系统会保存当前进程的上下文,并执行需要的特权操作。完成后,操作系统将控制权返回给hello进程,CPU重新进入用户态,并将保存的上下文信息恢复到CPU中。
图23 进程上下文切换
6.6 hello的异常与信号处理
hello程序执行过程中出现的异常可能有中断、陷阱、故障、终止等
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
hello具体运行过程中,可能产生SIGINT、SIGKILL、SIGSEGV、SIALARM、SIGCHLD等信号,具体处理如下:
- Ctrl+Z:进程收到SIGSTP信号,hello停止,此时进程并未回收,而是后台运行,通过ps指令可以对其进行查看,还可以通过fg指令将其调回前台。
图24 SIGSTP信号处理
- Ctrl+C:进程收到SIGINT信号,hello终止。在ps中查询不到此进程及其PID,在jobs中也没有显示。
图25 SIGINT信号处理
- 中途乱按:
图26 键盘乱按处理
- kill命令:挂起的进程被终止,在ps中无法查到到其PID。
图27 kill指令
- pstree命令:用树状图显示所有进程结构。
图28 pstree指令
6.7本章小结
简要介绍进程的基本概念,介绍fork函数和execve函数。介绍信号处理和异常处理的基本知识,在程序上对多种键盘输入进行测试。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:这是程序代码经过编译后出现在汇编程序中的地址。逻辑地址是用来指定一个操作数或者是一条指令的地址,通常是指相对于某个基准点的偏移量。在有地址变换功能的计算机中,访问指令给出的地址(操作数)就是逻辑地址,也称为相对地址。逻辑地址需要经过寻址方式的计算或变换才能得到内存储器中的物理地址。
线性地址:也称为虚拟地址,它是一个无符号整数,通常用来表示高达4GB的地址空间,也就是高达4294967296个内存单元。线性地址通常用十六进制数字表示,值域从0x00000000到0xfffffff。线性地址经过段机制的转换后成为物理地址。
物理地址:这是CPU地址总线传来的地址,由硬件电路控制。物理地址用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相应。
在这个存储器地址空间中,逻辑地址和线性地址都只是中间层的抽象,而物理地址是最终被硬件所识别的地址。这些地址空间的概念是操作系统进行内存管理和进程调度的基础。
执行hello程序时,程序的指令和数据首先被加载到逻辑地址空间,经过分段、分页转换后得到线性地址,最终通过页表机制转换为物理地址,才能被实际执行。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel处理器中,段式存储管理中以段为单位分配内存,每段分配一个连续的内存区,但各段之间不要求连续,且长度不一。其优点是易于编译、管理、修改和维护,但会产生内存的浪费。
完整的逻辑地址包含段选择符和段内偏移地址两部分。段选择符选择对应的段,在x86保护模式下,段描述符(段基线性地址、长度、权限等)无法直接存放在段寄存器中。Intel处理器将段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值。
将段首地址作为基地址加上偏移地址即可将逻辑地址映射到线性地址空间。
7.3 Hello的线性地址到物理地址的变换-页式管理
Hello程序的线性地址到物理地址的变换是通过页式管理来实现的,这是一种高级的内存管理技术。页式管理将物理内存划分为固定大小的页框(page frame),并将逻辑内存也划分为相同大小的页面(page)。每个页面可以映射到任何一个空闲的页框中。
具体的线性地址到物理地址的变换过程如下:
- CR3寄存器:处理器从CR3控制寄存器中获取页目录的基地址。CR3寄存器中存储的是页目录的起始物理地址。
- 页目录:线性地址的高10位被用作索引来访问页目录。页目录项中存储了对应页表的基地址。
- 页表:处理器使用线性地址的中间10位作为索引来访问页表。页表项中存储了目标页面的物理地址或者页面属性等信息。
- 物理地址:最后,处理器将线性地址的低12位作为页内偏移,与页表项中的物理地址相加,得到最终的物理地址。
通过页式管理,操作系统可以更加灵活地进行内存分配和管理。程序使用的逻辑内存可以被映射到不连续的物理内存上,提高了内存的利用率。同时,页式管理还提供了内存保护机制,每个页面都有自己的访问权限和属性,可以防止程序越界访问或者非法修改其他程序的内存空间。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB(Translation Lookaside Buffer)是一种高速缓存,用于存储最近被使用的虚拟地址(VA)到物理地址(PA)的映射关系。当CPU访问一个虚拟地址时,首先会查询TLB。如果在TLB中找到了对应的映射(命中),CPU就可以直接获取物理地址;如果未找到(未命中),则需要通过页表进行地址翻译。
在四级页表的支持下,CPU访问一个虚拟地址时,会提取出该虚拟地址中的页目录项索引、页表项索引和页内偏移,并通过四级页表来查找物理地址。四级页表中的每一级都有对应的页目录表和页表,可以将虚拟地址转换为物理地址。如果TLB未命中,会触发一次页表缺失异常,内核会处理该异常并将相关条目添加到TLB中。
TLB和四级页表结合使用能够显著提高地址翻译的效率,减少对内存访问的次数,从而提升系统的整体性能。
图29 Inter Core i7地址翻译
7.5 三级Cache支持下的物理内存访问
三级缓存是一种采用多级缓存的存储体系,可以提高计算机内存访问速度和效率。在三级缓存的架构中,缓存分为L1、L2和L3三级,每一级缓存都有不同的容量和访问速度。
当CPU需要访问内存时,首先在L1缓存中查找数据,先找组索引位,然后与标志位对比。如果L1缓存中未命中,则需要从存储层次结构中的下一层(即L2缓存)查找。如若仍未命中,则会继续在L3缓存中查找。如果在L3缓存中也未命中,则会从主存中获取数据。
图30 Inter Core i7的Cache结构
如果在三级缓存中找到了需要的数据,则可以直接访问缓存中的数据,从而提高访问速度。如果在三级缓存中没有找到,则需要从主存中获取数据,并将数据存入三级缓存中,以便下次访问时可以更快地获取数据。
7.6 hello进程fork时的内存映射
当一个进程使用fork()系统调用创建一个新的进程时,新进程(子进程)会继承父进程的内存映射。这意味着子进程将获得父进程的代码段、数据段、堆和栈等内存区域的副本。
以下是fork()系统调用后父子进程的内存映射情况:
- 代码段:子进程复制父进程的代码段,这是必须的,因为子进程需要执行相同的程序代码。
- 数据段:子进程复制父进程的数据段,包括全局变量和静态变量等。
- 堆:子进程通常会从堆的起始位置开始复制父进程的堆区域。但要注意,子进程可能不会复制整个堆,而是使用适当的内存管理机制来分配和释放内存。
- 栈:子进程也会复制父进程的栈区域。这是为了保存函数调用的返回地址和局部变量等。
需要注意的是,虽然子进程继承了父进程的内存映射,但它们是独立的进程,有自己的虚拟地址空间。对子进程的内存进行修改不会影响父进程的内存,反之亦然。此外,父子进程可以使用exec()系列函数来替换各自的内存映像,执行不同的程序。
总之,fork()系统调用创建了一个与父进程几乎完全相同的子进程,包括内存映射。这使得操作系统能够快速地创建新进程,并为其提供必要的资源来执行任务。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行新程序hello时,需要以下几个步骤:
- 删除已有页表和结构体vm_area_struct链表,删除当前进程虚拟地址的用户部分中的已存在的区域结构;
- 创建新的页表和结构体vm_area_struct链表,包括目标文件提供的代码和初始化的数据映射到.text和.data段,.bss和栈映射到匿名文件。所有这些新的区域都是私有的写时复制的。
- 将需要动态链接的libc.so映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC),指向代码区域的入口点,Linux根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
缺页故障指访问一个虚拟地址的数据时,对应的物理地址不在内存中,从而引发的异常情况。页面命中完全是由硬件完成的,而处理缺页异常是由硬件和操作系统内核协作完成的。
当发生缺页故障时,处理器会向操作系统发出缺页中断信号,缺页处理程序确定物理内存中的牺牲页(若页面被修改,则换出到磁盘),而后调入新的页面,并更新内存中的PTE。最后缺页处理程序返回到原来进程,再次执行导致缺页的指令,从而完成内存访问。
7.9本章小结
本章简要介绍存储相关的知识。介绍不同地址概念以及他们之间的转换,讲述fork和execve函数的存储映射,最后介绍缺页处理.
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux的IO设备管理方法主要包括以下几种:
- 文件系统接口:
- 在Linux中,所有的设备都被视为文件。这意味着,对设备的访问可以通过文件系统接口(如read、write、open、close等系统调用)来完成。每个设备被映射到文件系统的某个位置,称为设备文件。
- 设备文件通常位于/dev目录下,以文件名的形式存在,如/dev/sda代表磁盘设备、/dev/tty1代表终端设备等。
- 设备驱动程序:
- 设备驱动程序是Linux内核的一部分,负责与硬件设备进行通信和控制。
- 设备驱动程序向上提供统一的接口,供文件系统调用,使得应用程序可以通过标准文件IO操作读写设备。
- 设备驱动程序向下直接操作硬件设备,使用特定于设备的协议和控制方式进行通信。
- 字符设备和块设备:
- 字符设备:以字符为单位进行访问的设备,如终端设备、串口设备等。
- 块设备:以块为单位进行访问的设备,如硬盘、闪存等。
- 设备文件权限和访问控制:
- Linux中的设备文件具有权限属性,类似于普通文件,可以通过chmod和chown命令来修改权限。
- 访问设备通常需要root或者有相应权限的用户。
- 中断和轮询:
- 设备驱动程序可以使用中断机制或者轮询方式来处理设备的输入输出请求。
- 中断:当设备有数据准备好时,设备驱动程序会触发中断,通知内核进行数据处理。
- 轮询:设备驱动程序定期查询设备状态,检查是否有数据需要处理。
- 设备管理和设备树:
- Linux内核通过设备树(Device Tree)来管理系统中的硬件设备。设备树是描述硬件架构和连接信息的数据结构,用于在运行时动态构建设备的层次结构。
- 设备树使得内核能够动态识别和管理各种设备,包括处理器、外围设备等。
8.2 简述Unix IO接口及其函数
(以下格式自行编排,编辑时删除)Unix IO接口是通过文件描述符来访问各种IO设备的统一方式,它提供了一组函数来进行文件和设备的读写操作。以下是Unix IO接口中常见的函数及其作用:
- 打开和关闭文件:
int open(const char *path, int flags, mode_t mode)
打开指定路径的文件,并返回文件描述符。
path:文件路径。
flags:打开文件的方式,如只读、只写、读写等。
mode:文件权限,仅在创建文件时有效。
int close(int fd)
关闭指定文件描述符的文件。
- 读写文件:
ssize_t read(int fd, void *buf, size_t count)
从文件描述符 fd 指定的文件中读取 count 字节数据到 buf 中。
ssize_t write(int fd, const void *buf, size_t count)
将 buf 中 count 字节的数据写入到文件描述符 fd 指定的文件中。
- 文件位置操作:
off_t lseek(int fd, off_t offset, int whence)
设置文件描述符 fd 指定的文件的读写位置。
offset:偏移量。
whence:起始位置,可以是 SEEK_SET(文件开头)、SEEK_CUR(当前位置)、SEEK_END(文件结尾)。
- 文件描述符操作:
int dup(int oldfd)
复制文件描述符 oldfd,返回新的文件描述符。
int dup2(int oldfd, int newfd)
将文件描述符 oldfd 复制到 newfd,如果 newfd 已经打开,则先关闭。
- 文件和设备信息:
int fstat(int fd, struct stat *buf)
获取文件描述符 fd 关联的文件信息,并将其存储在 buf 结构体中。
int ioctl(int fd, unsigned long request, ...)
提供对特定设备的控制操作。
- 错误处理:
int perror(const char *s)
打印最近的系统错误信息,以字符串 s 开头。
8.3 printf的实现分析
printf的实现分析可以从用户程序调用开始,一直到字符显示在屏幕上的整个过程,包括库函数、系统调用、显示驱动等的协同工作。
- 用户程序调用printf:
printf("Hello, world!\n");
- 库函数vsprintf: printf函数首先调用vsprintf函数,将格式化后的字符串写入到缓冲区中。vsprintf函数的主要工作是将格式化的数据写入到内存中的缓冲区。这一步涉及到字符串的处理和格式化,例如将"Hello, world!\n"转换为ASCII码。
- write系统调用: 当缓冲区中的内容准备好后,printf通过write系统调用将数据发送到标准输出,即终端或控制台。write系统调用会将数据从用户空间复制到内核空间的缓冲区,并由内核进一步处理。
- 内核中的处理:
系统调用处理:内核收到write系统调用后,会执行系统调用处理程序。
调度到具体设备:内核会将数据调度到具体的输出设备,例如显示器。
- 显示驱动程序:
字符显示驱动子程序:内核调用相应的字符显示驱动子程序,它负责将数据从内核缓冲区传输到物理显示设备上的VRAM(视频内存)。
从ASCII到字模库:字符显示驱动子程序将ASCII字符转换为相应的字模(字形)。
显示VRAM:将经过处理的字模数据写入到VRAM中,VRAM中存储了每个点的RGB颜色信息。
- 显示芯片刷新:
刷新频率:显示芯片按照设定的刷新频率逐行读取VRAM中的数据。
信号传输:根据VRAM中存储的RGB颜色信息,显示芯片通过信号线将每个像素的RGB分量传输到液晶显示器。
- 显示在屏幕上:
液晶显示器:液晶显示器接收到RGB信号后,将每个像素的颜色显示在屏幕上。
8.4 getchar的实现分析
- 键盘中断处理:
- 键盘中断处理子程序:当用户按下键盘时,会触发键盘中断。处理器会跳转到预先定义的中断处理程序(IRQ1),即键盘中断处理子程序。
- 按键扫描码转换:键盘中断处理程序会读取键盘控制器中的扫描码(scan code),并将其转换为ASCII码或其他字符编码。
- 保存到键盘缓冲区:转换后的字符或ASCII码会被保存到系统的键盘缓冲区中,等待进程调用read系统函数读取。
- getchar的实现:
· 用户空间调用:在用户程序中调用getchar函数,例如:
char c = getchar();
调用过程:getchar函数实际上会调用read系统调用来获取用户输入的字符。
- read系统调用:
- 调用read:getchar函数内部会调用read系统调用,以读取标准输入(键盘)上的字符。
- 系统调用执行:read系统调用会将字符从内核空间的键盘缓冲区复制到用户空间的缓冲区中。
- 读取字符直到回车键:
- 循环读取:read系统调用在内核中会循环等待键盘输入。
- 直到回车键:系统调用会一直等待,直到用户按下回车键,将回车键的ASCII码('\n')返回给用户程序。
- 返回结果:
返回字符:一旦read系统调用收到回车键,getchar函数将返回这个字符给用户程序。
8.5本章小结
本章介绍了Linux的IO设备管理方法和Unix IO接口及其函数,并且详细论述了printf函数和getchar函数的执行流程,包括系统调用和IO管理。
(第8章1分)
结论
经过对hello.c程序从创建到执行的全过程探索,我对程序的生命周期有了更深刻的认识。
首先,hello.c文件经过预处理,生成了hello.i文件。接着,hello.i文件被编译,生成了hello.s汇编文件。随后,hello.s文件经过汇编,生成了可重定位目标文件hello.o。最终,hello.o文件与所需的库函数经过动态链接,生成了可执行目标文件hello。
运行hello时,首先由bash shell调用fork函数生成子进程,再调用execve函数载入hello程序,为其分配虚拟内存。当执行到入口点时,程序被载入物理内存。hello程序在CPU中独占一段时间,执行自己的逻辑控制流。在此期间,CPU通过MMU利用TLB和页表将虚拟地址映射为物理地址,进行内存访问。当程序遇到信号时,它会调用信号处理程序进行处理。最终,程序执行完毕后,shell父进程回收fork出来的子进程,内核删除该过程中创建的一切数据结构。
通过对hello程序生命周期的研究,我对程序的动态链接、shell的操作、fork和execve函数的作用、虚拟内存的功能、堆管理、IO管理以及信号处理等有了更深的理解。最初,我对虚拟内存和信号管理感到困惑,但随着一步步的探索,终于明白了其中的原理。随着对《计算机系统基础》课程的逐步理解,hello程序也走完了它的一生。
此次学习让我认识到计算机系统是由硬件和软件组成的复杂交互系统。硬件包括中央处理器、存储器和输入输出设备等物理组件;软件是运行在计算机上的程序和数据的集合。硬件和软件的协同工作,使计算机能够实现各种复杂功能。
在学习过程中,我了解到计算机的基本工作原理。当用户在键盘上输入一个字符时,键盘接口将字符发送到中央处理器,中央处理器根据存储器中的程序指令处理字符,并将结果存储回存储器或输出到显示器。这一过程中涉及数据在不同部件之间的传输和转换,包括数据总线和地址总线。
此外,我对操作系统有了更深入的了解。操作系统是计算机系统的核心软件,负责管理硬件和软件资源,提供用户界面和应用程序接口。通过学习操作系统的基本原理,我理解了进程管理、内存管理、文件系统和设备驱动程序等重要概念。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名称 | 功能 |
hello.c | hello的C语言源代码 |
hello.i | hello.c预处理后生成的中间文件 |
hello.s | hello.i编译后生成的汇编文件 |
hello.o | hello.s汇编后生成的可重定位目标文件 |
hello | hello.o和其他库文件经链接后生成的可执行文件 |
hello_o.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.elf | 由hello可执行文件生成的ELF格式文件 |
(附件0分,缺失 -1分)
参考文献
[1] RANDALE.BRYANT, DAVIDR.O’HALLARON. 深入理解计算机系统[M]. 机械工业出版社, 2011.
[2]进程的创建过程-fork函数https://blog.csdn.net/lyl194458/article/details/79695110
[3] argc argv的概念https://baike.baidu.com/item/argc%20argv/10826112?fr=aladdin
[4] https://www.cnblogs.com/pianist/p/3315801.html
(参考文献0分,缺失 -1分)