本文链接:https://blog.csdn.net/QingFeng_0813/article/details/139468749?spm=1001.2014.3001.5502
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 医学与健康学院
学 号 2022110762
班 级 2252003
学 生 刘佳龙
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
本研究详细追踪并解析了hello.c源文件从创建到转变为可执行程序的全过程。首先,源文件经历了预处理、编译、汇编和链接阶段,最终生成可执行文件。接着,研究了该程序在执行过程中如何成为进程,进程在操作系统中的运行情况,以及其最终被终止的过程。本文探讨了计算机在编译和运行hello.c时,在操作系统、处理器、内存和I/O设备等各层面之间的交互机制。
关键词:计算机系统;预处理;编译;汇编;链接;进程管理;存储管理;IO管理;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
1.2 环境与工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1 预处理的概念与作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.4 Hello.o的结果解析
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.6 hello的执行流程
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.6 hello的异常与信号处理
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
7.10本章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
附件
参考文献
第1章 概述
1.1 Hello简介
P2P(Program to Process)
Hello的P2P描述了hello.c文件从源代码转变为运行时进程的过程。在Linux系统中,hello.c文件需要经历多个步骤才能变为可执行程序:首先,通过cpp(C预处理器)进行预处理;接着,由ccl(C编译器)编译;然后,由as(汇编器)进行汇编;最后,由ld(链接器)进行链接,生成最终的可执行文件hello(在Linux系统中,此文件通常没有固定的后缀)。当在shell中输入命令./hello时,shell会通过fork创建一个子进程,使得hello从一个可执行文件变为一个正在运行的进程。
图1.1-1 编译系统
020(From 0 to 0)
Hello的020解释了hello.c文件从内存中无内容到程序运行结束、内存被清空的过程。起初,内存中没有hello文件的任何数据,这就是“From 0”。当在shell中使用execve函数时,系统将hello文件载入内存并执行其代码。程序执行完毕后,hello进程结束,内核会回收相关内存并删除hello文件的相关数据,这个过程即为“to 0”。
1.2 环境与工具
硬件环境:x64 CPU;3.20GHz;16G RAM
软件环境:Windows 11 64位;Ubuntu 20.04
开发与调试工具:edb,gcc,vim,objdump,readelf
1.3 中间结果
表1 中间结果文件
文件的名字 | 文件的作用 |
hello.i | hello.c预处理后得到的文本文件 |
hello.s | hello.i编译后得到的文本文件 |
hello.o | hello.s汇编后得到的二进制文件 |
hello.elf | hello.o的ELF格式文件 |
hello.asm | hello.o的反汇编文件 |
hello | hello.o链接得到的可执行目标文件 |
hello2.elf | hello的ELF格式文件 |
hello2.asm | hello的反汇编文件 |
1.4 本章小结
本章主要介绍了hello的P2P和020的整个过程、软硬件环境、开发工具以及相应的中间结果。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
1. 预处理的概念
预处理是编译之前的步骤,在此阶段,源代码会被扫描以识别并处理预处理指令和宏定义。此过程会替换包含预处理指令的语句和宏定义,还会移除代码中的注释和多余的空白字符,最终生成调整后的源代码供编译器使用。
2. 预处理的作用
预处理的主要功能可以分为以下三部分:
(1) 宏展开:预处理器会识别并展开程序中的“#define”标识符,将其替换为定义的实际值(例如字符串或代码段)。
(2) 文件包含复制:预处理器会处理用“#include”指令包含的文件,将这些文件的内容插入到该指令所在的位置,删除原指令,实现类似于复制粘贴的效果,使包含的文件和当前源文件合并成一个新的源文件。
(3) 条件编译处理:根据“#if”、“#endif”、“#ifdef”和“#ifndef”等指令后的条件,确定哪些部分的源代码需要编译。
2.2在Ubuntu下预处理的命令
图2.2-1 预处理命令截图
图2.2-2 hello.i截图
2.3 Hello的预处理结果解析
观察预处理结果后,我们发现hello.i是一个文本文件,内容仍然是C语言代码,但量非常庞大。带有-P参数的 hello.i 有近两千行,而不带-P参数的则有三千行。在文件结尾,我们可以看到我们的源程序代码,并且它并没有被修改。
这是因为在 hello.c 的头部引入了标准头文件 stdio.h、unistd.h 和 stdlib.h。预处理器将它们替换为系统文件中的内容。因此,即使我们的程序中没有写系统文件的内容,经过预处理器的替换后,我们仍然能够得到这些函数。在编译后,我们可以直接使用这些系统函数,而不需要在源程序中编写这些庞大而复杂的系统函数。
图2.2-3 hello.i截图
2.4 本章小结
本章主要探讨了预处理的概念、作用和命令,分析了hello.c源代码文件的预处理过程和结果,此过程深化了对C语言预处理的理解。C语言预处理一般由预处理器(cpp)进行,主要完成四项工作:宏展开、文件包含复制、条件编译处理和删除注释及多余空白字符。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译(Compilation)是指将高级程序源代码转换为计算机可以执行的目标代码,在这个过程中,程序源文件首先经过预处理器的处理,生成的预处理文件被转换为汇编程序。编译器(Compiler)负责将预处理文件中的高级语言转换为汇编语言,生成描述程序的汇编语言文本文件。
编译的作用:
1.将高级语言转换为机器可执行的目标代码:编译器将程序员用高级语言编写的代码转换为计算机硬件能够理解和执行的二进制指令或者汇编语言。
2.优化程序性能:编译器可以对程序进行优化,使得生成的目标代码在执行时能够更加高效地利用计算机资源,提高程序的运行速度和性能。
3.检查代码错误:编译器在编译过程中会检查代码的语法错误和类型错误等,提供编译错误信息,帮助程序员发现和修复代码中的问题。
4.提供平台无关性:编译器可以将高级语言代码编译成针对不同硬件平台的目标代码,从而实现程序的跨平台运行。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
图3.2-1编译命令
图3.2-2 hello.s截图
3.3 Hello的编译结果解析
3.3.1.1字符常量
图3.3.1-1字符常量对应汇编语言
分别为两个字符串提示符,放在静态存储区,不可改变,且一直存在。
3.3.1.2变量
(1)局部变量i。只在初次使用时赋值,储存在栈中,当所在函数返回,栈会被复原,局部变量在栈中所占的空间也会被释放。
图3.3.1-2 局部变量对应汇编语言
(2)函数参数。即形式参数,实际为局部变量,只在所调用的函数中起作用,函数调用开始时储存在栈中,当函数返回所占空间被释放。
图3.3.1-3 函数参数对应汇编语言
main将argc和argv作为局部变量存储在栈中(argc为main函数命令行参数个数,argv为存放参数字符串指针的数组)
3.3.1.3表达式
C 语言中的表达式分为多种类型,其中包括:变量常量表达式、算术表达式、赋值表达式、逗号表达式、关系表达式和逻辑表达式。复合表达式是其他表达式的组合,因此不需要单独讨论。在这里,我们将重点介绍其他五种表达式的情况。
(1)算术表达式:
算术表达式是使用运算符(一元或二元)将操作数连接起来的表达式,例如像 a + b 或 i++这样的形式。
在我们的程序中,i++表示每次循环都将i 自增 1。
图3.3.1-4 自增1算术表达式对应汇编语言
每次循环结束编译器使用ADD指令为储存在栈中的局部变量i增加1。
(2)赋值表达式
编译器使用MOV类指令将一个值赋给另一个地址:
图3.3.1-5 赋值表达式对应汇编语言
(3)关系表达式
编译器使用CMP对两个地址的值进行对比,然后根据比较结果使用jump指令跳转到相应地址。
图3.3.1-6 argc参数个数关系表达式对应汇编语言
图3-12,循环i关系表达式对应汇编语言
3.3.2赋值
源代码i=0,为i赋初值,通过movl指令实现:
图3.3.2-1 赋值例1截图
源代码i++,在每次循环i赋值为i+1,通过addl指令实现:
图3.3.2-2 赋值例2截图
3.3.3算术操作
源代码i++,通过addl指令实现:
图3.3.3-1 算术操作截图
3.3.4关系操作
源代码argc!=5,将argc与5比较,通过cmpl指令实现:
图3.3.4-1 关系操作例1截图
编译器使用 jump 指令进行跳转转移,通常用于实现判断或循环的分支操作。由于不同的逻辑表达式结果会导致程序执行不同的代码,编译器会先使用 CMP 指令更新条件码寄存器,然后根据比较结果使用相应的 jump指令跳转到对应代码的地址。
图3.3.5-1 hello.s中的控制转移1
图3.3.5-2 hello.s中的控制转移2
图3.3.5-3 hello.s中的控制转移3
argv 是一个字符串指针数组,其中存储着命令行输入的参数字符串的指针。我们可以通过数组内元素的地址来访问这些参数。假设 argv 被存储在 -32(%rbp),由于在 64 位系统中指针大小为 8 个字节,因此每个参数字符串地址相差 8 个字节。
编译器使用 -32(%rbp) 作为数组的起始基地址,然后通过不同的偏移量来访问各个字符串指针的地址。具体来说,编译器会使用以下计算方式来获取每个参数的地址:
第一个参数的地址:-32(%rbp)
第二个参数的地址:-32(%rbp) + 8
第三个参数的地址:-32(%rbp) + 16
第四个参数的地址:-32(%rbp) + 24
每个地址的计算都基于 rbp 寄存器减去一个固定的偏移量,再加上对应的字节数,以此来访问存储在 argv 数组中的各个字符串指针。
图3.3.6-1 hello.s中的数组操作对应汇编语言
3.3.7.1参数传递
在函数调用之前,编译器会将参数存储在寄存器中,以便被调用的函数使用。在参数个数不超过 6 个时,参数按以下优先级存放在寄存器中:rdi,rsi,rdx,rcx,r8,r9。如果参数个数超过 6 个,前六个参数使用寄存器存放,其余的参数则压入栈中。
在我们的程序中,多次进行了函数调用。例如,当参数不足时,程序会打印提示信息。此时,编译器将提示字符串的地址赋给rdi寄存器,并调用puts函数来输出提示信息。
图3.3.7-1 调用printf传递参数对应汇编语言
在程序中,通过相对寻址获取参数字符串的地址并将其加载到适当的寄存器中,例如 rdi, rsi, rdx,然后调用相应的函数。具体来说,使用 rdi 寄存器传递第一个参数给 atoi 函数,将字符串转换为整型值,并将结果传递给 sleep 函数来暂停程序执行。通过这种方式,可以正确地处理和转换参数字符串,并执行后续操作:
图3.3.7-2 调用atoi传递参数对应汇编语言
3.3.7.2 函数调用
程序使用汇编指令 call 加函数地址来调用函数。在 hello.o 文件中,由于没有重定位信息,编译器使用函数名作为助记符来代替具体的函数地址。当执行 call 指令时,程序会将返回地址压入栈中,为局部变量和函数参数建立栈帧,然后跳转到被调用函数的地址。
图3.3.7-3 函数调用
3.3.7.3 函数返回
程序使用汇编指令ret从调用的函数中返回,还原栈帧,返回栈中保存的返回地址。
图3.3.7-4,函数返回
3.4 本章小结
本章主要介绍了编译的概念与作用,在Ubuntu下编译的命令,对hello的编译结果进行解析,详细分析了编译器如何处理C语言中的数据、赋值、算术运算、关系操作、控制转移、数组操作和函数操作。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是指汇编器(assembler)将以 .s 结尾的汇编程序翻译成机器语言指令的过程。汇编器会将这些指令打包成可重定位的目标程序格式,并最终将结果保存到 .o 目标文件中。
4.1.2 汇编的作用
汇编的主要作用是将汇编语言转换为机器语言,并将这些指令以可重定位目标程序的格式保存到 .o 文件中。这样,程序在链接阶段可以使用这些目标文件生成最终的可执行文件。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
在Ubuntu下汇编的命令为:
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
汇编过程如下:
图4.2-1 汇编的命令
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先,在shell中输入readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF 格式:
图 4.3-1 生成ELF文件
其结构分析如下:
1.ELF 头(ELF Header):
以 16字节序列 Magic 开始,其描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
图4.3-2 ELF头
2.节头:
包含了文件中出现的各个节的意义,包括节的类型、位置和大小等信息。
图4.3-2 节头
3.重定位节
重定位节包含在代码中使用的一些外部变量和函数的符号信息。当程序进行链接时,链接器根据这些重定位信息对符号进行处理和修改,确保最终生成的可执行文件中的地址引用是正确的。
图4.3-3 重定位节
4.符号表Symbol table
符号表是目标文件中的一个关键数据结构,用于存储程序中所有符号(包括变量、函数等)的定义和引用信息。符号表中的每个条目描述了一个符号的属性,包括其名称、地址、大小、类型和作用域等。
图4.3-4 符号表
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o > hello.asm
图4.4-1 生成hello.asm文件
通过对比hello.asm与hello.s可知,两者在如下地方存在差异:
- 分支转移:
在hello.s中,跳转指令的目标地址直接记为段名称,如.L2,.L3等。而在反汇编得到的hello.asm中,跳转的目标为具体的地址,在机器代码中体现为目标指令地址与当前指令下一条指令的地址之差。
图4.4-1 分支转移
- 函数调用:
在hello.s文件中,call之后直接跟着函数名称,而在反汇编得到的hello.asm中,call 的目标地址是当前指令的下一条指令。这是因为 hello.c 中调用的函数都是共享库中的函数,最终需要通过动态链接器作用才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其 call 指令后的相对地址设置为全0(此时,目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接进一步确定。
图4.4-3 函数调用
- 全局变量访问:
在hello.s 文件中,使用段名称+%rip访问 rodata(printf 中的字符串),而在反汇编得到的hello.asm中,使用 0+%rip进行访问。其原因与函数调用类似,rodata 中数据地址在运行时才能确定,故访问时也需要重定位。在汇编成为机器语言时,将操作数设置为全 0 并添加相应的重定位条目。
图4.4-4 全局变量访问
4.5 本章小结
本章探讨了汇编的基本概念及其作用,详细介绍了在Ubuntu环境下如何将hello.s文件转换为hello.o文件,并生成ELF格式的hello.elf文件。同时,本章还研究了ELF格式文件的结构。通过对比hello.o的反汇编代码(保存在hello.asm中)与hello.s中的代码,我们深入了解了汇编语言与机器语言之间的差异和联系。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是链接器将一个或多个可重定位目标文件(包括特殊的共享目标文件)通过符号解析和重定位生成可执行目标文件的过程。链接包括静态链接和加载时共享库的动态链接。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
在Ubuntu下链接的命令如下:
ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o hello.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o -o hello
图5.2-1 链接命令截图
5.3 可执行目标文件hello的格式
5.3.1readelf命令
命令:readelf -a hello >hello2.elf
图5.3.1-1 readelf命令截图
5.3.2ELF头
hello2.elf中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以描述了生成该文件的系统的字的大小和字节顺序的16字节序列 Magic 开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与hello.elf相比较,hello2.elf中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。与hello.o的ELF头对比可以发现,首先类型变成了EXEC(可执行文件),并且在hello的ELF头中大部分在hello.o的ELF头中暂时被填为0的条目被重新填充了,且于是各起始地点也相应向后移动了。
图5.3.2-1 ELF头截图
5.3.3节头
描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并为一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
图5.3.3-1 节头截图
5.3.4重定位节
重定位节内容变为需要动态链接调用的函数,同时重定位类型发生改变。
图5.3.4-1 重定位节截图
5.3.5符号表
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明
图5.3.5-1 符号表截图
5.4 hello的虚拟地址空间
使用edb加载hello:
图5.4-1 edb加载hello截图
从Data Dump窗口可以看到hello的虚拟地址空间分配情况。
图5.4-2 Data Dump窗口截图
init段起始地址:0x401000
图5.4-3 .init段起始地址截图
text段起始地址:0x4010f0
图5.4-4 .text段起始地址截图
以此类推,节头中的各段在edb中均能对应找到,说明节头表中存储各段的起始地址与各段的虚拟地址之间存在对应关系。
查看 ELF 格式文件中的 Program Headers,它告诉链接器运行时加载的内容,并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的各方面的信息。
程序包含:
PHDR 保存程序头表;
INTERP 指定在程序已经从可执行文件映射到内存之后,必须调用的解释器;
LOAD 表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等;
DYNAMIC 保存了由动态链接器使用的信息;
NOTE 保存辅助信息;
GNU_STACK,权限标志,用于标志栈是否是可执行;
GNU_RELRO,指定在重定位结束之后哪些内存区域是需要设置只读。
5.5 链接的重定位过程分析
在Shell中使用命令objdump -d -r hello > hello2.asm生成反汇编文件hello2.asm,与第四章中生成的hello.o.asm文件进行比较,其不同之处如下:
图5.5-1 生产.asm文件
1.链接后函数数量增加。链接后的反汇编文件hello2.asm中,多出了puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。
图5.5-2 链接后的函数
2.函数调用指令call的参数发生变化。在链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。
图5.5-3 跳转指令的参数
3.跳转指令参数发生变化。在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
图 5.5-4 跳转指令的参数
5.6 hello的执行流程
5.6.1执行过程
图5.6-1 edb执行hello截图
1.从加载hello到_start:程序先调用_init函数,之后是puts、printf等库函数,最后调用_start函数。
2.从_start到call main:程序先调用__libc_csu_init等函数,完成初始化工作,随后调用main函数。
3.从main函数到程序终止:程序执行main函数调用main函数用到的一些函数,main函数执行完毕之后调用__libc_csu_fini、_fini完成资源释放和清理的工作。
5.6.2子程序名或程序地址
0000000000401000 <_init>
0000000000401020 <.plt>
0000000000401030 <puts@plt>
0000000000401040 <printf@plt>
0000000000401050 <getchar@plt>
0000000000401060 <atoi@plt>
0000000000401070 <exit@plt>
0000000000401080 <sleep@plt>
00000000004010f0 <_start>
0000000000401120 <_dl_relocate_static_pie>
0000000000401125 <main>
00000000004011d0 <__libc_csu_init>
0000000000401240 <__libc_csu_fini>
0000000000401248 <_fini>
5.7 Hello的动态链接分析
在elf文件中:
图 5.7-1 hello的elf文件内容
进入edb查看:
图 5.7-2 edb执行init前的地址
图 5.7-3 edb执行init后的地址
一开始地址的字节都为0,调用_init函数之后GOT内容产生变化,指向正确的内存地址,下一次调用跳转时可以跳转到正确位置。
5.8 本章小结
本章介绍了链接的概念和功能,分析可执行文件hello的ELF格式及其虚拟地址空间,并对重定位、动态链接进行深入的分析。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程可以被定义为正在执行的程序的一个实例。每个程序在系统中都在一个特定的进程上下文中运行。这个上下文包含了程序正确执行所需的状态信息,包括内存中程序的代码和数据、栈、通用寄存器的内容、程序计数器、环境变量和打开的文件描述符等。
作用:每当用户通过 shell 输入可执行文件的名称以运行程序时,shell 会创建一个新的进程,并在这个新进程的上下文中执行该可执行文件。应用程序也可以创建新的进程,并在这些新进程的上下文中运行自身代码或其他应用程序。这样,系统可以有效地管理和调度各个程序的执行。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell是指为使用者提供操作界面的软件,是一个交互型应用级程序,它接收用户命令然后调用相应的应用程序。shell提供了用户与内核进行交互操作的接口,最重要的功能是命令解释。Linux系统上的所有可执行文件都可以作为shell命令来执行,同时它也提供一些内置命令。此外shell还包括通配符、命令补全、命令历史、重定向、管道、命令替换等功能。
Shell的处理流程大致如下:
- 从Shell终端读入输入的命令。
- 切分输入字符串,获得并识别所有的参数
- 若输入参数为内置命令,则立即执行
- 若输入参数并非内置命令,则调用相应的程序为其分配子进程并运行
- 若输入参数非法,则返回错误信息
- 处理完当前参数后继续处理下一参数,直到处理完毕
6.3 Hello的fork进程创建过程
父进程通过调用 fork函数来创建一个新的子进程。这个新创建的子进程几乎完全复制了父进程的环境,包括代码段、数据段、堆、共享库以及用户栈,但它们是相互独立的副本。
在创建子进程时,子进程会继承父进程中任何打开的文件描述符的副本。这意味着子进程可以读写父进程中打开的文件。父进程和子进程之间的主要区别在于它们拥有不同的进程ID(PID)。
fork 函数的独特之处在于它只调用一次,却返回两次:一次在父进程中返回,返回值是新创建的子进程的PID;一次在子进程中返回,返回值为0。通过检查 `fork` 的返回值,程序可以判断当前执行的是父进程还是子进程。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行指定的可执行文件(如 hello),并传递参数列表 argv 和环境变量列表 envp。与 fork不同,fork 函数调用一次会返回两次:一次在父进程中,另一次在子进程中。而 execve函数则只调用一次,并且永远不会返回到调用程序,除非发生错误(例如找不到指定的 hello 文件)。因此,execve会将当前进程完全替换为新的可执行文件的进程映像。
6.5 Hello的进程执行
在程序运行时,Shell 会为hello 程序 fork 一个子进程。这个子进程有独立的逻辑控制流,与 Shell 分开运行。在 `hello` 程序的执行过程中,如果该进程不被抢占,它会正常执行;如果被抢占,系统会进入内核模式,进行上下文切换,然后再转入用户模式以调度其他进程。
当hello调用 sleep 函数时,为了最大化处理器资源的利用,sleep 会向内核发送请求将 hello挂起,并进行上下文切换。系统会切换到内核模式,将当前运行的 hello进程从运行队列中移除,放入等待队列,接着切换到其他进程并返回用户模式,继续执行被抢占的进程。
在 hello 进程被放入等待队列后,内核会开始计时。计时结束时,sleep函数返回,触发中断,使得 hello 进程重新被调度。系统会将 hello 从等待队列中移出,切换回内核模式,然后转为用户模式。此时,hello进程可以继续执行其逻辑控制流。
6.6 hello的异常与信号处理
hello执行过程中会出现4类异常:
信号处理方式:
图 6.6-1 中断处理
图 6.6-2 陷阱处理
图 6.6-3故障处理
图 6.6-4 终止处理
程序运行过程中,分别进行如下操作:
- 回车
回车不影响正常运行,只是插入了空行。
图6.6-6回车运行结果
- Ctrl-Z
进程收到SIGTSTP信号,暂时挂起,输入ps命令符查看PID发现hello的PID为6262,hello进程还没有被关闭,输入fg命令恢复后台进程。
再次Ctrl-Z后,输入“kill -9 -6262”命令后,hello进程被终止,输入ps命令进程hello不存在。
图 6.6-7 Ctrl-Z运行结果
输入pstree查看进程树:
图6.6-7 进程树(部分)
图 6.6-8 进程树(部分)
- Ctrl-C
发送SIGINT信号,结束hello。在ps中没有其相关信息。
图 6.6-9 Ctrl-Z运行结果
- 不停乱按
输入的字符被保存在缓冲区,被认为是命令,在程序结束后执行。
图 6.6-10 中途乱按运行结果
6.7本章小结
在这一部分,我们探讨了hello进程的概念以及shell的功能。我们详细分析了hello进程的执行过程,探讨了fork和execve在程序运行中的作用。此外,我们还对信号处理进行了了解,并深入研究了hello进程在内核和用户空间之间如何反复跳跃执行的情况。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是程序产生的与段相关的偏移地址部分,如hello.o中的相对偏移地址。
线性地址是地址空间中的连续整数地址集合,即hello程序中的虚拟内存地址。CPU生成的虚拟地址即为虚拟地址,如hello程序中的虚拟内存地址。
物理地址则是计算机主存中每个字节唯一的地址,即运行时虚拟内存地址对应的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
为了最大限度地利用内存空间,Intel 8086设置了四个段寄存器,用于存储段地址:CS(代码段寄存器)、DS(数据段寄存器)、SS(堆栈段寄存器)、ES(附加段寄存器)。
当程序执行时,需要确定代码、数据和堆栈在内存中的位置,这通过设置段寄存器CS、DS和SS来指示这些起始位置。通常情况下,DS保持不变,而根据需要修改CS。这使得程序可以在小于64K的可寻址空间内以任意大小编写,限制了程序及其数据组合的大小在DS指向的64K内,这也是COM文件大小限制为64K的原因。
段寄存器是为了对内存进行分段管理而设置的。在描述内存分段时,需要考虑段的大小、起始地址和管理属性(如写入权限、执行权限、系统专用等)。
在保护模式中,段寄存器存放段选择符,其中前13位是一个索引号,后面的3位包含一些硬件细节。根据段选择符,处理器在全局段描述符表(GDT)或局部段描述符表(LDT)中查找段描述符,然后使用段地址和偏移地址计算线性地址。
而在实模式中,段寄存器包含段值。在访问内存时,处理器将相应的段寄存器值乘以16,形成20位的段基地址,然后通过段基地址加上偏移地址来计算线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
Intel处理器通过页式管理将线性地址转换为物理地址,具体步骤如下:
1. 获取页目录基地址:
从控制寄存器CR3中取出当前进程的页目录的地址,并取出其前20位,这是页目录的基地址。
2. 计算页目录项地址:
使用页目录基地址和线性地址的前10位进行组合,得到线性地址前10位的索引对应的项在页目录中的地址。通过该地址可以取到页目录项,该项的值即为二级页表的基址。
3. 计算页表项地址:
根据第二步得到的二级页表基址,取其前20位,并将线性地址的第10到第19位左移2位,按照与第二步相同的方式进行组合,得到线性地址对应的物理页框在内存中的地址在二级页表中的地址。读取该地址上的值,就得到了线性地址对应的物理页框在内存中的基地址。
4. 计算物理地址:
使用第三步得到的物理页框基地址的前20位,再根据线性地址的最后12位偏移量,计算出具体的物理地址。读取该物理地址上的值,即为最终需要的值。
7.4 TLB与四级页表支持下的VA到PA的变换
如果TLB命中,MMU从TLB中取出相应的PTE,将虚拟地址翻译为物理地址。如果TLB不命中,根据VPN1在一级页表中选择对应的PTE,该PTE包含二级页表的基地址;根据VPN2在二级页表中选择对应的PTE,该PTE包含三级页表的基地址;根据VPN3在三级页表中选择对应的PTE,该PTE包含四级页表的基地址;在四级页表中取出对应的PPN,与VPO串联起来,就得到相应的物理地址。
7.5 三级Cache支持下的物理内存访问
得到物理地址PA之后,根据缓存大小和组数的要求,将PA拆分成CT(标记)、CI(索引)和CO(偏移量)。用CI位进行索引,如果匹配成功且valid值为1,则为命中,根据偏移量在L1缓存中取数。如果未命中,就去二级和三级缓存中重复以上步骤,命中后返回结果。7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含可执行目标文件hello中的程序,加载、运行 hello 需要以下步骤:
1. 删除 shell 虚拟地址的用户部分中的已存在的区域结构。
2. 映射私有区域。为 hello 的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text 和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中。栈和堆区域也是请求二进制零的,初始长度为零。
3. 映射共享区域。如果 hello 程序与共享对象(或目标)链接,比如标准 C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4. 设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
经过此内存映射的过程,下一次调度hello进程时,就能够从hello的入口点开始执行了。
7.8 缺页故障与缺页中断处理
当发生缺页异常时,控制会转移到内核的缺页处理程序。缺页处理程序首先判断虚拟地址是否合法。如果虚拟地址不合法,则会产生一个段错误并终止进程。如果虚拟地址合法,缺页处理程序会执行以下步骤:
1. 确定牺牲页:从物理内存中选择一个页面作为牺牲页。如果该牺牲页已被修改过,则将其内容写回到磁盘(换出)。
2. 换入新页面:将所需的新页面从磁盘读入物理内存中,并更新页表以反映新页面的位置。
3. 返回处理器:缺页处理程序完成后,控制返回给CPU,重新执行引起缺页的指令,并将缺页的虚拟地址重新发送给MMU。
由于新的虚拟页面现在已缓存在物理内存中,此时会命中,主存将所请求的字返回给处理器,从而继续执行程序。
7.9动态存储分配管理
动态内存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种风格——显式分配器和隐式分配器。C语言中的malloc程序包是一种显式分配器。显式分配器必须在一些相当严格的约束条件下工作:①处理任意请求序列;②立即响应请求;③只使用堆;④对齐块(对齐要求);⑤不修改已分配的块。在以上限制条件下,分配器要最大化吞吐率和内存使用率。
常见的放置策略包括:首次适配,从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配,类似于首次适配,但从上一次查找结束的地方开始搜索。最佳适配,选择所有空闲块中适合所需请求大小的最小空闲块。
一些组织内存块的方法包括:
1. 隐式空闲链表:空闲块通过头部中大小字段隐含连接,可添加边界标记提高合并空闲块的速度。
2. 显式空闲链表:在隐式空闲链表块结构的基础上,在每个空闲块中添加一个前驱(pred)指针和一个后继(succ)指针。
3. 分离的空闲链表:将块按块大小划分大小类,分配器维护一个空闲链表数组,每个大小类一个空闲链表,减少分配时间同时也提高了内存利用率。C语言中的malloc程序包采用的就是这种方法。
4. 红黑树等树形结构:按块大小将空闲块组织为树形结构,同样有减少分配时间和提高内存利用率的作用。
7.10本章小结
在本章中,我们深入探讨了hello程序的存储器地址空间,介绍了Intel处理器的段式管理以及hello程序的页式管理。详细讲解了在特定环境下VA到PA的转换过程,涉及了TLB的作用以及缺页异常处理的流程。此外,还探讨了进程fork和execve时的内存映射机制,以及动态内存分配管理所涉及的主要内容,包括不同的分配器风格和常见的放置策略。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
设备的模型化将所有的 I/O 设备(如网络、磁盘和终端)都抽象为文件的形式。因此,对这些设备的读写操作就可以像对文件一样进行。这种文件系统的设计方式使得操作系统能够以统一的方式管理设备,并提供了一种简单、低级的应用接口,即 Unix I/O 接口。
在 Unix I/O 接口中,所有的输入和输出操作都通过文件描述符进行。每个打开的文件(包括设备文件)都有一个相应的文件描述符,应用程序可以通过文件描述符来进行读取和写入操作,而无需了解底层设备的细节。这种设计使得应用程序能够以一种统一且一致的方式与不同类型的设备进行交互,大大简化了程序的开发和维护工作。
8.2 简述Unix IO接口及其函数
8.2.1Unix IO接口
1. 打开文件“应用程序通过调用 open 函数来打开一个已存在的文件或创建一个新文件。open 函数将文件名转换为文件描述符,并返回一个小的非负整数作为描述符,用于在后续的文件操作中标识该文件。打开的文件信息由内核管理,并保留在相应的数据结构中。对于每个进程,通常有三个预先打开的文件:标准输入、标准输出和标准错误。
2. 改变当前文件位置:对于每个打开的文件,内核维护着一个文件位置 k,初始值为0,表示从文件开头开始的字节偏移量。应用程序可以通过执行 seek 函数来显式地改变当前文件位置 k。
3. 读写文件:读操作从文件中复制指定数量的字节到内存中,并将当前文件位置 k 增加相应的字节数。写操作从内存中复制指定数量的字节到文件中,并更新当前文件位置 k。
4. 关闭文件:应用程序通过调用 close 函数来关闭一个打开的文件。close 函数释放文件打开时创建的数据结构,并将文件描述符恢复到可用的描述符池中。
8.2.1Unix IO函数:
打开文件:open()函数
关闭文件:close()函数
读取文件:read()函数,从当前文件位置复制字节到内存位置,如果返回值<0则说明出现错误。
写入文件:write()函数,从内存复制字节到当前文件位置,如果返回值<0则说明出现错误。
改变文件位置:lseek()函数,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
8.3 printf的实现分析
printf函数:
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。printf用了两个外部函数,一个是vsprintf,还有一个是write。
所引用的vsprintf函数:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
vsprintf函数的作用是将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。
write函数是将buf中的i个元素写到终端的函数。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar函数在程序中的作用是等待用户从键盘输入信息,并且在用户输入有效信息时将其放入字符缓冲区。具体来说:
当程序调用getchar 时,程序会等待用户从键盘输入信息。
输入的字符被放入字符缓冲区,但getchar不会立即处理。
当用户输入回车键时,getchar会从字符缓冲区以字符为单位读取数据,但不会读取回车键和文件结束符。
getchar的实现依赖于异步异常,即键盘中断处理程序。当用户进行键盘输入时,计算机接收到键盘中断信号,并从当前进程跳转到键盘中断处理程序。
键盘中断处理程序将键盘中断通码和断码转换为按键编码,并将其缓存到键盘缓冲区。
然后控制器将任务交换回原来的任务(即getchar所在的进程),如果没有遇到回车键,则继续等待用户输入,重复上述过程。
当 getchar 遇到回车键时,它会按字节读取键盘缓冲区中的内容,并处理完毕后返回。这样 getchar 进程就结束了。
8.5本章小结
本章介绍了Unix I/O,通过LinuxI/O设备管理方法以及Unix I/O接口及函数了解系统级I/O的底层实现机制,并通过对printf和getchar函数的底层解析加深对Unix I/O以及异常中断等的了解。
(第8章1分)
hello的一生主要经过一下过程:
1. 程序员编写了 hello.c,并存储在内存中。
2. 预处理器处理 hello.c 得到 hello.i。
3. 编译器将 hello.i 编译成汇编语言得到 hello.s。
4. 汇编器将 hello.s 汇编成可重定位目标文件 hello.o。
5. 链接器将 hello.o 和其他目标文件链接生成可执行目标文件 hello。
6. 在 shell 中运行 hello 程序。
7. Shell 创建子进程并调用 hello。
8. hello 程序运行,调用 execve 加载程序。
9. 执行 hello 程序的逻辑控制流。
10. 通过三级缓存访问内存,将虚拟地址映射成物理地址。
11. 处理信号和异常控制流,根据不同的信号执行不同的操作。
12. 当进程结束或被 kill 时,回收子进程。
深入理解计算机系统的各个方面,从硬件到软件,从底层到高层,都对于计算机科学领域的从业者来说至关重要。这种理解可以帮助我们更好地设计和优化程序,更有效地管理系统资源,以及更好地保护系统安全性。掌握这些知识可以使我们成为更全面、更有竞争力的计算机专业人员。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 功能 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 袁春风. 计算机系统基础. 北京:机械工业出版社,2018.7(2019.8重印)
[2] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2018.4
(参考文献0分,缺失 -1分)