计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 数据科学与大数据技术
学 号 2022113216
班 级 2203501
学 生 汤弋
指 导 教 师 吴锐
计算机科学与技术学院
2024年5月
本篇论文的目的是解释C语言程序如何从源代码转换为可执行文件。以hello.c程序为例,详细分析了计算机在生成hello可执行文件的预处理、编译、汇编、链接、进程管理等整个生命周期。在理论上探讨这些工具的原理和方法的同时,实机演示他们的操作过程和结果阐述了计算机系统的工作原理和体系结构,帮助读者更深入地理解和掌握C语言程序的编译和执行过程。
关键词:计算机系统;体系结构;生命周期;进程管理;存储管理;
目 录
第1章 概述
1.1 Hello简介
P2P:即From Program to Process。指从hello.c(Program)变为运行时进程(Process)。要让hello.c这个C语言程序运行起来,需要先把它变成可执行文件,这个变化过程有四个阶段:预处理,编译,汇编,链接,完成后就得到了可执行文件,然后就可以在shell中执行它,shell会给它分配进程空间。
020:即From Zero-0 to Zero-0。指最初内存并无hello文件的相关内容,shell用execve函数启动hello程序,把虚拟内存对应到物理内存,并从程序入口开始加载和运行,进入main函数执行目标代码,程序结束后,shell父进程回收hello进程,内核删除hello文件相关的数据结构。
1.2 环境与工具
处理器:Intel Core i7 7700HQ
机带RAM:16.0GB
系统类型:64位操作系统,基于x64的处理器
软件环境:Windows11 64位,VMware,Ubuntu 20.04 LTS,Centos
开发与调试工具:Visual Studio 2021 64位;vim objump edb gcc readelf等工具
1.3 中间结果
hello.s 编译后得到的汇编语言文件
hello.o 汇编后得到的可重定位目标文件
hello.asm 反汇编hello.o得到的反汇编文件
hello1.asm 反汇编hello可执行文件得到的反汇编文件
1.4 本章小结
本章介绍了hello的P2P,020流程,包括流程的设计思路和实现方法;详细说明了本实验所需的硬件配置、软件平台、开发工具以及本实验生成的各个中间结果文件的名称和功能。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理步骤是指预处理器在程序运行前,对源文件进行简单加工的过程。预处理过程主要进行代码文本的替换工作,用于处理以#开头的指令,还会删除程序中的注释和多余的空白字符。预处理指令可以简单理解为#开头的正确指令,它们会被转换为实际代码中的内容。
2.1.2预处理的作用
预处理过程中并不直接解析程序源代码的内容,而是对源代码进行相应的分割、处理和替换,主要有以下作用:
- 头文件包含:将所包含头文件的指令替代。
2. 宏定义:将宏定义替换为实际代码中的内容。
3. 条件编译:根据条件判断是否编译某段代码。
4. 其他:如注释删除等。
简单来说,预处理是一个文本插入与替换的过程预处理器。
2.2在Ubuntu下预处理的命令
预处理的命令:gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
在Linux下打开hello.i文件,我们对比了源程序和预处理后的程序。结果显示,除了预处理指令被扩展成了几千行之外,源程序的其他部分都保持不变。
在main函数代码出现之前的大段代码源自于的头文件<stdio.h> <unistd.h> <stdlib.h> 的依次展开。
以 stdio.h 的展开为例:预处理过程中,#include指令的作用是把指定的头文件的内容包含到源文件中。stdio.h是标准输入输出库的头文件,它包含了用于读写文件、标准输入输出的函数原型和宏定义等内容。
当预处理器遇到#include<stdio.h>时,它会在系统的头文件路径下查找stdio.h文件,一般在/usr/include目录下,然后把stdio.h文件中的内容复制到源文件中。stdio.h文件中可能还有其他的#include指令,比如#include<stddef.h>或#include<features.h>等,这些头文件也会被递归地展开到源文件中。
预处理器不会对头文件中的内容做任何计算或转换,只是简单地复制和替换。
2.4 本章小结
本章讲述了在linux环境中,如何用命令对C语言程序进行预处理,以及预处理的含义和作用。然后用一个简单的hello程序演示了从hello.c到hello.i的过程,并用具体的代码分析了预处理后的结果。通过分析,我们可以发现预处理后的文件hello.i包含了标准输入输出库stdio.h的内容,以及一些宏和常量的定义,还有一些行号信息和条件编译指令。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
计算机程序编译的概念是指将用高级程序设计语言书写的源程序,翻译成 等价的汇编语言格式程序的翻译过程。
3.1.2编译的作用
计算机程序编译的作用是使高级语言源程序变为汇编语言,提高编程效率 和可移植性。计算机程序编译的基本流程包括词法分析、语法分析、语义分析、 中间代码生成、代码优化和目标代码生成等阶段。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1汇编初始部分
在main函数前有一部分字段展示了节名称:
.file 声明出源文件
.text 表示代码节
.section .rodata 表示只读数据段
.align 声明对指令或者数据的存放地址进行对齐的方式
.string 声明一个字符串
.globl 声明全局变量
.type 声明一个符号的类型
3.3.2 数据部分
(1)字符串程序有两个字符串存放在只读数据段中,如图:
hello.c中唯一的数组是main函数中的第二个参数(即char*argv[]),数组的每个元素都是一个指向字符类型的指针。由知数组起始地址存放在栈中-32(%rbp)的位置,被两次调用作为参数传到printf中。
(2)参数argc(数组)
参数argc是main函数的第一个参数,被存放在寄存器%edi中,由语句
可见寄存器%edi地址被压入栈中,而语句
可知该地址上的数值与立即数4判断大小,从而得知argc被存放在寄存器并被压入栈中。
(3)局部变量
程序中的局部变量只有i,我们根据
可知局部变量i是被存放在栈上-4(%rbp)的位置。
3.3.3全局函数
我们通过汇编代码
.globl段可以知道。hello.c中只声明了一个全局函数int main(int arge,.char*argv[])。
3.3.4赋值操作
hel1o.c中的赋值操作贝有for循环开头的i=0,该赋值操作体现在汇编代码上,则是用mov指令实现,如图:
。由于int型变量i是一个32位变量,使用movl传递双字实现。
3.3.5算术操作
hello.c中的算术操作为for循环的每次循环结束后i++,该操作体现在汇编代码则使用指令add实现,问样,由丁变量i为32位,使用指令addl。指令如下:
3.3.6关系操作
hello.c中存在两个关系操作,分别为:
1. 条件判断语句if(argc!=4):汇编代码将这条代码翻译为:
使用了cmp指令比较立即5和参数argc大小,并且设置了条件码。根据条件码,如果不相等则执行该指令后面的语句,否则跳转到.L2。
2. 在for循环每次循环结束要判断一次i<10,判断循环条件被翻译为:
同(1),设置条件码,并通过条件码判断跳转到什么位置。
3.3.7控制转移指令
设置过条件码后,通过条件码来进行控制转移,在本程序中存在两个控制转移:
(1)
判断argc是否为5,如果不为5,则执行if语句,否则执行其他语句,在汇编代码中则表现为如果条件码为1,则跳到.L2,否则执行cmpl指令后的指令。
(2)
在for循环每次结束判断一次i<10,翻译为汇编语言后,通过条件码判断每次循环是否跳转到.L4。而在for循环初始要对i设置为0,如下:
然后直接无条件跳转到.L3循环体。
3.3.8函数操作
(1)main函数
参数传递:该函数的参数为int argc,,char*argv[]。具体参数传递地址和值都在前面阐述过。
函数调用:通过使用call内部指令调用语句进行函数调用,并且将要调用的函数地址数据写入栈中,然后自动跳转到这个调用函数内部。main函数里调用了printf、exit、sleep函数。
局部变量:使用了局部变量i用于for循环。具体局部变量的地址和值都在前面阐述过。
(2)printf函数
参数传递:printf函数调用参数argv[1],argv[2],argv[3]
函数调用:该函数调用了两次。第一次将寄存器%rdi设置为待传递字符串"用法: Hello 学号 姓名 手机号 秒数!\n"的起始地址;第二次将其设置为“Hello %s %s %s\n”的起始地址。使用寄存器%rsi完成对argv[1]的传递,用%rdx完成对argv[2]的传递,%rcx完成对argv[3]的传递。
(3)exit函数
参数传递与函数调用
将edi设置为1,再使用call指令调用函数。
(4)atoi、sleep函数
参数传递与函数调用:
可见,atoi函数将参数argv[4]放入寄存器%rdi中用作参数传递,简单使用call指令调用。
然后,将转换完成的秒数从%eax传递到%edi中,edi存放sleep的参数,再使用call调用。
(5)getchar函数
无参数传递,直接使用call调用即可。
3.3.9类型转换
atoi函数将宁字符中转换为sleep函数需要的整型参数.
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.4 本章小结
这一章介绍了C编译器如何把hello.i文件转换成hello.s文件的过程,简要说明了编译的含义和功能,演示了编译的指令,并通过分析生成的hello.s文件中的汇编代码,探讨了数据处理,函数调用,赋值、算术、关系等运算以及控制跳转和类型转换等方面,比较了源代码和汇编代码分别是怎样实现这些操作的。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
汇编是指汇编器(as)将包含汇编语言的.s文件翻译为机器语言指令,并把 这些指令打包成为一个可重定位目标文件的格式,生成目标文件.o文件。.o文 件是一个二进制文件,包含main函数的指令编码。
4.1.2汇编的作用
汇编就是将高级语言转化为机器可直接识别执行的代码文件的过程,汇编 器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的 格式。 .o 文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
4.3 可重定位目标elf格式
在shell中输入readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF 格式,并输入到hello.elf文件中查看:
4.3.1生成ELF格式的可重定位目标文件
典型的ELF格式的可重定位目标文件的结构有:ELF头、节头、重定位节、符号表等。
4.3.2查看ELF格式文件的内容
(1)ELF头
ELF头(ELF header)以一个l6字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、系统架构(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。ELF头展示如下:
(2)节头(section header)
记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
(3)重定位节
.rel.text节是一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改,而调用本地函数的指令不需修改。可执行目标文件中不包含重定位信息。如图,需要重定位的内容如下:
4)符号表
.symtab节中包含ELF符号表,这张符号表包含一个条目的数组,存放一个程序定义和引用的全局变量和函数的信息。该符号表不包含局部变量的信息。符号表如下:
每个可重定位目标模块 m 都有一个符号表,它包含 m 定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
1、由模块 m 定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的 C 函数和全局变量。
2、由其他模块定义并被模块 m 引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态 C 函数和全局变量。
3、只被模块 m 定义和引用的局部符号。它们对应于带 static 属性的 C 函数和全局变量。这些符号在模块 m 中任何位置都可见,但是不能被其他模块引用
READELF用一个整数索引来标识每个节,Ndx=1标识.text节,Nex=UND(UNDEF)则表示未定义的符号,即在本目标模块中引用但是在其他模块中定义的符号
4.4 Hello.o的结果解析
4.4.1命令
在shell中输入objdump -d -r hello.o > hello.asm 指令输出hello.o的反汇编文件,并与第3章的hello.s文件进行对照分析。
4.4.2与hel1o.s的对照分析
(1)增加机器语言
每一条指令增加了一个十六进制的表示,即该指令的机器语言。
(2)操作数进制
反汇编文件中的所有操作数都改为十六进制。如(1)中图所示,立即数由hello.s中的$5变为了$0x5,地址表示也由-20(%rbp)变为-0x14(%rbp)。可见只是进制表示改变,数值未发生改变。
(3)分支转移
反汇编的跳转指令中,所有跳转的位置被表示为主函数+段内偏移量这样确定的地址,而不再是段名称(例如.L2)。例如下面的je指令,反汇编文件中为
而hello.s文件中为
(4)函数调用
反汇编文件中对函数的调用与重定位条目相对应。观察下面两个call指令调用函数,在hello.s中为
在可重定位文件中call后面不再是函数名称,而是一条重定位条目指引的信息。
4.5 本章小结
这一章介绍了汇编的含义和功能。以hello.s文件为例,说明了如何把它汇编成hello.o文件,并生成ELF格式的可执行文件hello.elf。将可重定位目标文件改为ELF格式观察文件内容,对文件中的每个节进行简单解析。通过分析hello.o的反汇编代码(保存在hello.asm中)和hello.s的区别和相同点,让人清楚地理解了汇编语言到机器语言的转换过程,以及机器为了链接而做的准备工作。
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
链接(linkng)是将各种代码和数据片段收集并组合为一个单一文件的过 程,这个文件可被加载(复制)到内存并执行。链接可以执行与编译时(compile time),也就是在源代码被翻译为机器代码时;也可以执行与加载时(load time), 也就 是程序被加载器加载到内存并执行时:甚至执行于运行时。
5.1.2链接的作用
在现代系统中,链接是由叫做链接器(1iker)的程序自动执行的,它们使得 分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文 件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些 模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接 应用。
5.2 在Ubuntu下链接的命令
gcc链接命令为:
gcc hello.o -o hello
ld链接的命令为:
ld -o hello -dynamic-linker /usr/lib64/ld-linux-x86-64.so.2 /usr/lib/crt1.0 /usr/lib/crti.o hello.o /usr/lib/libc.so /usr/lib/crtn.o运行截图如下:
5.3 可执行目标文件hello的格式
使用readelf解析hello的ELF格式,得到hello的节信息和段信息:
可执行目标文件中没有伪节
(1)ELF头(ELF Header)
hello1.elf中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以描述了生成该文件的系统的字的大小和字节顺序的16字节序列Magic开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与hello.elf相比较,hello1.elf中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。
(2)节头
描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件 的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个 符号的地址。
(3)程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
(4)段节
(5)重定位节
完成了重定位,加数都是0
(6)符号表
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位 需要引用的符号都在其中声明。不同的是,main函数以外的符号也拥有了type, 这证明完成了链接。
(7)程序头
ELF 可执行文件被设计得很容易加载到内存,可执行文件的连续的片 (chunk)被映射到连续的内存段。程序头部表(program header table)描述了 这种映射关系。
头部表参数类型:
off: 目标文件中的偏移
vaddr/paddr: 内存地址
align: 对齐要求
filesz: 目标文件中的段大小
memsz: 内存中的段大小
flags: 运行时访问权限
5.4 hello的虚拟地址空间
使用edb加载hello,Data Dump窗口观察hello加载到虚拟地址的情况,查
看各段信息。如图:
程序从地址0x400000开始到0x401000被载入,虚拟地址从0x400000 -0x400f00结束,根据5.3中的节头部表,可以通过edb找到各段的信息。
PHDR: 程序头表
INTERP: 程序执行前需要调用的解释器
LOAD: 保存常量数据、程序目标代码等
DYNAMIC: 保存动态链接器使用信息
NOTE: 保存辅助信息
GNU_STACK: 异常标记
GNU_RELRO: 保存重定位后只读区域的位置
5.5 链接的重定位过程分析
一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:
1、重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的. data 节被全部合并成一个节,这个节成为输出的可执行目标文件的. data 节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
2、重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目(relocation entry)的数据结构。
5.5.1分析hello与hello.o区别
对比hello和hello.o反汇编文件的区别:
使用命令objdump -d -r hello >helloout.asm生成反汇编文件。
与第四章中生成的hello.asm文件进行比较,其不同之处如下:
(1)链接后函数数量增加
链接后的反汇编文件helloout.asm中,多出了.plt,puts@plt,printf@plt, getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享 库中hello.c用到的函数加入可执行文件中。
(2)函数调用指令call的参数发生变化
在链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器 直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到 完整的反汇编代码。
(3)跳转指令参数发生变化
在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应 位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的 反汇编代码。
5.5.2重定位过程
重定位由两步组成:
(1)重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的聚合节。然后链接器将运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。至此程序中每条指令和全局变量都有唯一的运行内存地址。
(2)重定位节中的符号引用。这一步中链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目的数据结构。
(3)重定位过程地址计算方法如下:
5.6 hello的执行流程
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址:
_init | 0x401000 |
.plt | 0x401020 |
puts@plt | 0x401090 |
printf@plt | 0x4010a0 |
getchar@plt | 0x4010b0 |
atoi@plt | 0x4010c0 |
exit@plt | 0x4010d0 |
sleep@plt | 0x4010e0 |
_start | 0x4010f0 |
main | 0x4011d6 |
_fini | 0x40127c |
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。延迟绑定是通过GOT和PLT实现的,根据hello.elf文件可知,GOT起始表位置为:0x404000:
GOT表位置在调用dl_init之前0x404008后的16个字节均为0:
调用了dl_init之后字节改变了:
对于变量而言,利用代码段和数据段的相对位置不变的原则去计算正确地址。
对于库函数而言,需要plt、got合作。plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。接下来执行程序的过程中,就可以使用过程链接表plt和全局偏移量表got进行动态链接。
5.8 本章小结
本章首先阐述了链接的基本概念和作用,展示了使用命令链接生成hello可执行文件,观察了hello文件ELF格式下的内容,利用edb观察了hello文件的虚拟地址空间使用情况,最后以hello程序为例对重定位过程、执行过程和动态链接进行分析。链接中包含了许多提高程序执行效率、减小空间浪费的措施,如静态链接库、动态链接共享库等,为编写高效的程序提供了思路经过链接,已经得到了一个可执行文件,接下来只需要在 shell 中调用命令就 可以为这一文件创建进程并执行该文件。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程的经典定义就是一个执行中程序的实例。进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
6.1.2进程的作用
进程为程序提供了一种假象,程序好像是独占的使用处理器和内存,处理器好像是无间断地一条接一条地执行我们程序中的指令。进程作为一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用
Shell是一个交互型应用级程序,也被称为命令解析器,它为用户提供一个操作界面,接受用户输入的命令,并调度相应的应用程序。
6.2.2 Shell-bash的处理流程
首先从终端读入输入的命令,对输入的命令进行解析,如果该命令为内置命令,则立即执行命令,否则调用fork创建一个新的子进程,在该子进程的上下文中执行指定的程序。判断该程序为前台程序还是后台程序,如果为前台程序则等待程序执行结束,若为后台程序则将其放回后台并返回。在过程中shell可以接受从键盘输入的信号并对其进行处理。
6.3 Hello的fork进程创建过程
首先用户再shel1界面输入指令:./hel1o 2022113216 汤弋 电话号 5。Shell判断该指令不是内置命令,于是父进程调用fork函数创建一个新的子进程,该子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程与父进程最大的区别就是具有不同的PID。在父进程中,fork返回子进程的PID,而在子进程中fork返回0,返回值提供一个明确的方法来分辨程序是父进程还是在子进程中执行。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个程序。函数声明如下:
int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并不返回。
6.5 Hello的进程执行
hello程序在运行时,进程提供给应用程序的抽象有:(1)一个独立的逻辑控制流,它提供一个假象,好像我们的进程独占地使用处理器;(2)一个私有的地址空问,它提供一个假象,好像我们的程序独占地使用CPU内存。
有关操作系统提供的抽象:
操作系统提供的抽象有:
(1)逻辑控制流。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称为逻辑流。一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。
(2)上下文切换。操作系统内核使用一种称为上下文切换的叫高层形式的异常控制流来实现多任务。内核为每一个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需状态。
(3)时间片。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
(4)用户模式和内核模式。处理器通常使用某个控制寄存器中的一个模式位来提供这种功能。当设置了模式位时,进程就运行在内核模式里。一个运行在内核模式的进程可以执行指令集中的所有指令且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,也不能直接引用地址空间中内核区内的代码和数据。
(5)上下文信息。上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。hello程序执行过程中,在进程调用execve函数后,进程就为hello程序分配新的虚拟地址空间,开始时程序运行在用户模式中,调用printf函数输出“Hello 2022113216 汤弋 电话号”,之后调用sleep函数,进程进入内核模式,运行信号处理程序,再返回用户模式,运行过程中,cpu不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用cpu,实现进程的调度。
6.6 hello的异常与信号处理
6.6.1异常的分类
6.6.2异常的处理方式
6.7本章小结
本章的主要内容是探讨计算机系统中的进程和shell,首先通过一个简单的hello程序,简要介绍了进程的概念和作用、shell的作用和处理流程,还详细分析了hello程序的进程创建、启动和执行过程,最后,本章对hello程序可能出现的异常情况,以及运行结果中的各种输入进行了解释和说明。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
在有地址变换功能的计算机中,访问指令给出的地址(操作数)叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。逻辑地址是由一个段标识符加上一个指定段内相对地址的偏移量,由程序hello产生的与段相关的偏移地址部分
7.1.2线性地址
线性地址是逻辑地址到物理地址变换之间的一步,程序hello的代码会产生逻辑地址,在分段部件中逻辑地址是段中的偏移地址,加上基地址就是线性地址。
7.1.3虚拟地址
程序访问存储器所使用的逻辑地址称为虚拟地址。虚拟地址经过地址翻译得到物理地址。与实际物理内存容量无关,是hello中的虚拟地址
7.1.4物理地址
在存储器里以字节为单位存储信息,每一个字节单元给一个唯一的存储器地址,这个地址称为物理地址,是hello的实际地址或绝对地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理是指把一个程序分成若干个段进行存储,每个段都是一个逻辑实体。段式管理是通过段表进行的,包括段号(段名)、段起点、装入位、段的长度等。程序通过分段划分为多个块,如代码段、数据段、共享段等。
一个逻辑地址是两部分组成的,包括段标识符和段内偏移量。段标识符是由一个16位长的字段组成的,称为段选择符。其中前13位是一个索引号,后3位为一些硬件细节。索引号即是“段描述符”的索引,段描述符具体地址描述了一个段,很多个段描述符就组成了段描述符表。通过段标识符的前13位直接在段描述符表中找到一个具体的段描述符。
全局描述符表(GDT)整个系统只有一个,它包含:(1)操作系统使用的代码段、数据段、堆栈段的描述符(2)各任务、程序的LDT(局部描述符表)段。
每个任务程序有一个独立的LDT,包含:(1)对应任务/程序私有的代码段、数据段、堆栈段的描述符(2)对应任务/程序使用的门描述符:任务门、调用门等。
段式管理图示如下:
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。VM系统将虚拟内存分割,称为虚拟页,类似地,物理内存也被分割成物理页。利用页表来管理虚拟页,页表就是一个页表条目(PTE)的数组,每个PTE由一个有效位和一个位地址字段组成,有效位表明了该虚拟页当前是否被缓存在DRAM中,如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,如果发生缺页,则从磁盘读取。
MMU利用页表来实现从虚拟地址到物理地址的翻译。
下面为页式管理的图示:
7.4 TLB与四级页表支持下的VA到PA的变换
Core i7采用四级页表的层次结构。CPU产生虚拟地址VA,虚拟地址VA传送给MU,MMU使用VPN高位作为TLBT和TLBI,向TLB中寻找匹配。如果命中,则得到物理地址PA。如果TLB中没有命中,MMU查询页表,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,以此类推,最终在第四级页表中找到PPN,与VPO组合成物理地址PA,添加到PLT。工作原理如下:
7.5 三级Cache支持下的物理内存访问
如图为高速缓存存储器组织结构:
高速缓存的结构将m个地址位划分成了t个标记位,s个组索引位和b个块偏移位:
如果选中的组存在一行有效位为1,且标记位与地址中的标记位相匹配,我们就得到了一个缓存命中,否则就称为缓存不命中。如果缓存不命中,那么它需要从存储器层次结构的下一层中取出被请求的块,然后将新的块存储在组索引位指示组中的一个高速缓存行中,具体替换哪一行取决于替换策略,例如LRU策略会替换最后一次访问时间最久远的那一行。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_ struct、.区域结构和页表的原样副本。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任何一个。后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数加载并运行 a.out 需要以下几个步骤:
1、删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2、映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 a.out 文件中的. text 和. data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 a.out 中。栈和堆区域也是请求二进制零的,初始长度为零。图 9-31 概括了私有区域的不同映射。
3、映射共享区域。如果 a.out 程序与共享对象(或目标)链接,比如标准 C 库 libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器(PC)。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点
7.8 缺页故障与缺页中断处理
如果程序执行过程中发生了缺页故障,则内核调用缺页处理程序。处理程序执行如下步骤:
(1)检查虚拟地址是否合法,如果不合法则触发一个段错误,终止这个进程。
(2)检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。
(3)两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放。这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存管理机制,主要包含两方面内容,用户申请内存空间时,系统如何分配;用户使用内存空间完成后,系统如何及时回收。另外,内存不断分配与回收的过程,会产生诸多内存碎片,但通过利用数据结构,内存碎片化的问题能够得到有效的解决。
1、内存分配器的基本思路的实现:
处理任意请求序列:一个应用可以有任意的分配请求和释放请求序列,只要满足约束条件:每个释放请求必须对应于一个当前已分配块,这个块是由一个以前的分配请求获得的。因此,分配器不可以假设分配和释放请求的顺序。例如,分配器不能假设所有的分配请求都有相匹配的释放请求,或者有相匹配的分配和空闲请求是嵌套的。
立即响应请求:分配器必须立即响应分配请求。因此,不允许分配器为了提高性能重新排列或者缓冲请求。
只使用堆:为了使分配器是可扩展的,分配器使用的任何非标量数据结构都必须保存在堆里。
对齐块(对齐要求):分配器必须对齐块,使得它们可以保存任何类型的数据对象。
不修改已分配的块:分配器只能操作或者改变空闲块。特别是,一旦块被分配了,就不允许修改或者移动它了。因此,诸如压缩已分配块这样的技术是不允许使用的
使用不同的链表逻辑来实现不同性能的分配器:
隐式空闲链表:通过一个单向的链表实现
显式空闲链表:通过一个带有前驱和后继的双向链表实现,缩短查找空闲块的时间
分离的空闲链表。
2、垃圾回收机制
一般使用free函数实现申请空间的回收,为了回收那些申请了但是没有显式的回收的空间,使用垃圾回收机制。垃圾回收机制直观上使用一个有向可达图来判断一个内存块是否需要回收
7.10本章小结
本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,以intel Core i7在指定环境下介绍了虚拟地址VA到物理地址PA的转换、物理内存访问,分析了hello进程fork时的内存映射、hello进程、execve时的内存映射、缺页故障与缺页中断处理以及动态内存分配机制。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件。所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行
设备管理:unix io接口。Linux内核有一个简单、低级的接口,称为Unix I/O,使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1. Unix/IO接口:
(1). 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)和标准错误(描述符为 2)。头文件 <unistd.h> 定义了常量
STDIN_FILENO、
STDOUT_FILENO 、
STDERR_FILENO,
它们可用来代替显式的描述符值。
(2). 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置 k,初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行 seek 操作,显式地设置文件的当前位置为 k。
(3). 读写文件。一个读操作就是从文件复制 n > 0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k+n。给定一个大小为 m 字节的文件,当k⩾m时执行读操作会触发一个称为 end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的 “EOF 符号”。类似地,写操作就是从内存复制 n > 0 个字节到一个文件,从当前文件位置 k 开始,然后更新 k。
(4). 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
2. Unix/IO函数:
(1). open:打开文件
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(char *filename, int flags, mode_t mode);
// 返回:若成功则为新文件描述符,若出错为 -1。
flags 参数指明了进程打算如何访问这个文件:
O_RDONLY:只读。
O_WRONLY:只写。
O_RDWR:可读可写。
mode 参数指定了新文件的访问权限位
(2). close:关闭文件
#include <unistd.h>
int close(int fd);
// 返回:若成功则为 0,若出错则为 -1
(3). 读写文件
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n);
// 返回:若成功则为读的字节数,若 EOF 则为0,若出错为 -1。
ssize_t write(int fd, const void *buf, size_t n);
// 返回:若成功则为写的字节数,若出错则为 -1。
read 函数从描述符为 fd 的当前文件位置复制最多 n 个字节到内存位置 buf。返回值 -1 表示一个错误,而返回值 0 表示 EOF。否则,返回值表示的 是实际传送的字节数量。
write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件 位置。图 10-3 展示了一个程序使用 read 和 write 调用一次一个字节地从标 准输入复制到标准输出。
8.3 printf的实现分析
printf函数包含在头文件<stdio.h>中
__fortify_function int
printf (const char *__restrict __fmt, ...)
{
return __printf_chk (__USE_FORTIFY_LEVEL -1, __fmt, __va_arg_pack ());
}
# elif !defined __cplusplus
# define printf(...) \
__printf_chk (__USE_FORTIFY_LEVEL - 1, __VA_ARGS__)
# define fprintf(stream, ...) \
__fprintf_chk (stream, __USE_FORTIFY_LEVEL - 1, __VA_ARGS__)
# endif
函数原型如下:
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;
}
(char*)(&fmt) + 4) :表示的是...(可变参数)中的第一个参数的地址从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar函数包含在头文件<stdio.h>中:
/* Read a character from stdin. */
__STDIO_INLINE int
getchar (void)
{
return getc (stdin);
}
实际上调用的是Unix/IO函数getc。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了系统级IO、Unix/IO的基本知识,I/O 是系统操作不可或缺的一部分,学习系统的IO有助于理解其他的系统概念。最后分析了printf、getchar两个标准化的IO函数
结论
首先由程序员将hello代码从键盘输入,依次要经过以下步骤:
1、预处理(cpp)。将hello.c进行预处理,将文件调用的所有外部库文件合并展开,生成一个经过修改的hello.i文件。
2、编译(ccl)。将hello.i文件翻译成为一个包含汇编语言的文件hello.s。
3、汇编(as)。将hello.s翻译成为一个可重定位目标文件hello.o。
4、链接(ld)。将hello.o文件和可重定位目标文件和动态链接库链接起来,生成一个可执行目标文件hello。
5、运行。在shel1中输入./hello 2021113211 郑文翔。
6、创建进程。终端判断输入的指令不是shell内置指令,于是调用fork函数创建一个新的子进程。
7、加载程序。shell调用execve函数,启动加载器,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数。
8、执行指令:CPU为进程分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。
9、访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
10、信号管理:当程序在运行的时候我们输入Ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
11、终止:当子进程执行完成时,发送SIGCHLD信号给父进程,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构。
感悟:通过本次实验,加深了我对计算机系统的理解,知道了程序是怎么样一步一步运行起来的,深入理解了一个程序的生命周期。感受到了计算机的强大功能以及计算机系统设计的精妙。
附件
文件名 | 功能 |
hello.c | 源程序 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
helloout.asm | 反汇编hello可执行文件得到的反汇编文件 |
hello | 可执行文件 |
helloout.elf | 用readelf读取hello得到的ELF格式信息 |
参考文献
为完成本次大作业你翻阅的书籍与网站等
[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.