计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 2023111813
班 级 23L0518
学 生 鲁永哲
指 导 教 师 史先俊
计算机科学与技术学院
2025年5月
摘要
本篇论文解释了C语言程序如何从源代码转换为可执行文件。以hello.c程序为例,本文详细分析了计算机在生成hello可执行文件的预处理、编译、汇编、链接、进程管理等整个生命周期。探讨了这些工具的原理和方法,演示了它们的操作和结果,阐述了计算机系统的工作原理和体系结构,从而更深入地理解和掌握C语言程序的编译和执行过程。
关键词:计算机系统;体系结构;生命周期
目录
第1章 概述
1.1 Hello简介
1.2 环境与工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
2.1.2预处理的作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
3.1.2编译的作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.3.1汇编初始部分
3.3.2 数据部分
3.3.3全局函数
3.3.4赋值操作
3.3.5算术操作
3.3.6关系操作
3.3.7 控制转移指令
3.3.8函数操作
3.3.9类型转换
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
4.1.2汇编的作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.3.1 elf头
4.3.2 节头(section header)
4.3.3重定位节
4.3.4 符号表
4.4 Hello.o的结果解析
4.4.1命令
4.4.2 增加机器语言
4.4.3 操作数进制
4.4.4 分支转移
4.4.5 函数调用
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
5.1.2链接的作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.3.2 节头
5.3.3 符号表
5.3.4程序头
5.3.5 动态节
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.5.1分析hello与hello.o区别
5.5.2重定位过程
5.6 hello的执行流程
5.6.1 执行流程
5.6.2子程序名和地址
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
6.1.2 进程的作用
6.2 简述壳Shell-bash的作用与处理流程
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.5.1 逻辑控制流
6.5.2 时间片
6.5.3 上下文切换
6.5.4 用户态和内核态
6.5.5上下文信息
6.6 hello的异常与信号处理
6.6.1异常
6.6.2信号
6.6.3 异常的处理方式
6.6.4 运行结果及相关命令
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
7.1.2线性地址
7.1.3虚拟地址
7.1.4物理地址
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本章小结
结论
附件
参考文献
第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 环境与工具
处理器:AMD Ryzen7 7730U with Radeon Graphics
机带RAM:16.0GB
系统类型:64位操作系统,基于x64的处理器
软件环境:Windows11 64位,VMware,Ubuntu 20.04 LTS
开发与调试工具:Visual Studio 2021 64位;Clion 2024 64位;vim objdump edb gcc gdb等工具
1.3 中间结果
hello.i 预处理后得到的文本文件
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.2在Ubuntu下预处理的命令
预处理的命令:gcc -E hello.c -o hello.i
图1 预处理命令截图
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 部分hello.i截图
2.4 本章小结
本章讲述了在linux环境中,如何对C语言程序进行预处理,以及预处理的含义和作用。用hello程序演示了从hello.c到hello.i的过程,并分析了预处理后的结果。通过分析,可以发现预处理后的文件hello.i包含了标准输入输出库stdio.h的内容,以及一些宏和常量的定义,还有一些条件编译指令。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
计算机程序编译的概念是指将用高级程序设计语言书写的源程序,翻译成等价的汇编语言格式程序的翻译过程。
3.1.2编译的作用
计算机程序编译的作用是使高级语言源程序变为汇编语言,提高编程效率和可移植性。计算机程序编译的基本流程包括词法分析、语法分析、语义分析、中间代码生成、代码优化和目标代码生成等阶段。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
编译的命令:gcc -S hello.i -o hello.s
图3 编译的命令截图
3.3 Hello的编译结果解析
3.3.1汇编初始部分
main函数前有部分字段展示了节名称:
图4 hello.s初始部分
.file 声明出源文件
.text 表示代码节
.section .rodata 表示只读数据段
.align 声明对指令或者数据的存放地址进行对齐的方式
.string 声明一个字符串
.globl 声明全局变量
.type 声明一个符号的类型
3.3.2 数据部分
(1)参数argc
参数argc是main函数的第一个参数,被存放在寄存器%edi中
寄存器%edi地址被压入栈
该地址上的数值与立即数5判断大小,从而得知argc被存放在寄存器并被压入栈中。
(2)字符串程序有两个字符串存放在只读数据段中,如图:
hello.c中唯一的数组是main函数中的第二个参数(即char*argv),数组的每个元素都是一个指向字符类型的指针。由知数组起始地址存放在栈中-32(%rbp)的位置,被两次调用作为参数传到printf中。
分别将rdi设置为两个字符串的起始地址:
(3)局部变量
局部变量i是被存放在栈上-4(%rbp)的位置。
3.3.3全局函数
hello.c中只声明了一个全局函数int main(int arge,char*argv[])
3.3.4赋值操作
赋值操作有for循环开头的i=0,该赋值操作体现在汇编代码上是用mov指令实现。
int型变量i是一个32位变量,使用movl传递双字实现。
3.3.5算术操作
hello.c中的算术操作为for循环的每次循环结束后i++,在汇编代码使用指令add实现,由于变量i为32位,使用指令addl。
3.3.6关系操作
hello.c中存在两个关系操作,分别为:
- 条件判断语句if(argc!=5)
使用了cmpl指令比较立即数5和参数argc大小,并且设置了条件码,如果不相等则执行该指令后面语句,否则跳转到.L2。
2.在for循环每次循环结束要判断一次i<10
也设置了条件码,并通过条件码判断跳转到什么位置。
3.3.7 控制转移指令
设置过条件码后,通过条件码来进行控制转移,在本程序中存在两个控制转移:
1.
使用了cmpl指令比较立即数5和参数argc大小,并且设置了条件码,如果不相等则执行该指令后面语句,否则跳转到.L2。
2.
在for循环每次结束判断一次i<10,通过条件码判断每次循环是否跳转到.L4。而在for循环初始要对i设置为0。
然后直接无条件跳转到.L3循环体。
3.3.8函数操作
(1)main函数
参数传递:参数为int argc,char*argv[]。参数传递地址和值见3.3.2(1)(2)
函数调用:通过使用call内部指令调用语句进行函数调用,并且将要调用的函数地址数据写入栈中,然后自动跳转到这个调用函数内部。main函数里调用了printf、exit、sleep函数。
局部变量:使用了局部变量i用于for循环。具体局部变量的地址和值见3.3.2(3)与3.3.4。
(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函数需要的整型参数。
3.4 本章小结
这一章介绍了C编译器如何把hello.i文件转换成hello.s文件的过程,简要说明了编译的含义和功能,演示了编译的指令,并分析了生成的hello.s文件中的汇编代码,探讨了数据处理,函数调用,赋值、算术、关系等运算以及控制跳转和类型转换等方面。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
汇编是指汇编器(as)将包含汇编语言的.s文件翻译为机器语言指令,并把这些指令打包成为一个可重定位目标文件的格式,生成目标文件.o文件。.o文件是一个二进制文件,包含程序的指令编码。
4.1.2汇编的作用
汇编就是将高级语言转化为机器可直接识别执行的代码文件的过程,汇编器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式。 .o 文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
汇编命令:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
图5 汇编命令
4.3 可重定位目标elf格式
在shell中输入readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF 格式
图6 hello.o 的ELF格式命令
4.3.1 elf头
.o文件直接使用文本编辑器查看会出现乱码。选择查看可重定位目标文件的elf形式,使用命令readelf -h hello.o查看elf头。
ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
图7 hello.elf
4.3.2 节头(section header)
使用命令readelf -S hello.o查看节头。每个节都有一个对应的节头表,用于描述和定位各个节的信息,通过节头表,可以获取关于每个节的详细信息,如名称、类型、大小、地址、偏移、旗标、链接、信息、对齐、读写访问权限等信息。
图8 节头
4.3.3重定位节
终端输入readelf -r hello.o可查看可重定位段信息。
.rel.text节是一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改,而调用本地函数的指令不需修改。可执行目标文件中不包含重定位信息。
图9 重定位节
偏移量表示需要被修改的引用的节偏移,符号值标识被修改引用应该指向的符号。类型告知连接器如何修改新的引用,加数是一个有符号常数,一些类型的重定位要使用它对被修改的引用的值做偏移调整。
4.3.4 符号表
终端输入命令readelf -s hello.o得到符号表
.symtab 节中包含 ELF 符号表,这张符号表包含一个条目的数组,存放一个程序定义和引用的全局变量和函数的信息。该符号表不包含局部变量的信息。
图10 符号表
Num为某个符号的编号,Name是符号的名称。Size表示其是一个位于.text节中偏移量为0处的153字节函数,Bind表示这个符号是本地的还是全局的。
4.4 Hello.o的结果解析
4.4.1命令
objdump -d -r hello.o > hello.asm分析hello.o的反汇编,并与第3章的 hello.s进行对照分析。
图11 hello.o 反汇编
4.4.2 增加机器语言
每一条指令增加了一个十六进制的表示,即该指令的机器语言。而hello.s中并没有。如下图:
hello.s中的部分指令
hello.asm中的部分指令
4.4.3 操作数进制
反汇编文件中的所有操作数都由十进制改为十六进制。
由4.4.2的截图不难看出,立即数由hello.s中的$5变为了$0x5,地址表示也由-20(%rbp)变为-0x14(%rbp)。可见进制表示改变,数值并未发生改变。
4.4.4 分支转移
反汇编的跳转指令中,所有跳转的位置表示为主函数+段内偏移量,而不再是段名称(例如.L3)。例如下面的jmp指令,反汇编文件中为
而hello.s文件中为
4.4.5 函数调用
hello.s中,函数调用直接使用了函数的名称。反汇编代码中,call目标地址是当前指令的下一条指令地址。因为hello.c中调用的函数都是共享库(如stdio.h,stdlib.h)中的函数,如puts,exit,printf,需等待链接器链接之后才能确定响应函数地址。机器语言中对于这种不确定地址的调用,会将下条指令的相对地址全部设置为0,然后在.rel.text节中为其添加重定位条目,等待链接时确定地址。
观察下面两个call指令调用函数,在hello.s中为
而在反汇编文件中调用函数为
可重定位文件中call后面不再是函数名称,而是一条重定位条目指引的信息。
4.5 本章小结
这一章介绍了汇编的含义和功能。以Ubuntu系统下的hello.s文件为例,说明了如何把它汇编成hello.o文件,并生成ELF格式的可执行文件hello.elf。将可重定位目标文件改为ELF格式观察文件内容,对文件中的每个节进行简单解析。通过分析hello.o的反汇编代码(保存在hello.asm中)和hello.s的区别和相同点,让我理解了汇编不仅完成了从汇编语言到机器语言的转换,还通过生成可重定位目标文件,为后续链接和程序执行提供基础。
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
链接是将各个单独的二进制代码文件加载到同一个文件,并使之可以加载到内存中执行的一个过程。链接可以在编译时被执行,也可以在程序运行中被执行。文件表示为若干个.o文件被合并成一个单独的可执行文件(Linux下默认为.out文件)。
5.1.2链接的作用
在现代系统中,链接是由链接器(1inker)自动执行的,使分离编译成为可能。不用再将一个大型的应用程序组织为一个巨大的源文件,而可以把它分解为更小、更好管理的模块,允许独立地修改和编译这些模块。当改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用。
5.2 在Ubuntu下链接的命令
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
图12 链接命令
5.3 可执行目标文件hello的格式
5.3.1 ELF头(ELF Header)
终端输入readelf -h hello > hello1.elf查看hello文件的elf头。
hello1.elf中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以描述了生成该文件的系统的字的大小和字节顺序的16字节序列Magic开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与hello.elf相比较,hello1.elf中的基本信息未发生改变(如Magic,类别等),而类型改变为可执行文件EXEC,程序头大小和节头数量增加,并且获得了入口地址。
图13 hello的ELF头
5.3.2 节头
终端输入命令readelf -S hello查看节头部表信息。
图14 hello的节头
elf文件中,每个节都有一个对应的节头表,用于描述和定位各个节的信息,通过节头表,可以获取关于每个节的详细信息,如名称、偏移、大小等。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
5.3.3 符号表
终端输入命令readelf -s hello查看符号表
图15 hello的符号表
可以发现经过链接之后符号表的符号数量显著增加,说明经过连接之后引入了许多其他库函数的符号加入到了符号表中。
5.3.4程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
图16 程序头
5.3.5 动态节
Dynamic Section(动态节)是 ELF文件中的一个关键数据结构,用于存储程序动态链接所需的元信息。它包含一系列标记-值对(tag-value pairs),记录程序依赖的共享库(如 DT_NEEDED)、符号表地址(DT_SYMTAB)、字符串表(DT_STRTAB)、重定位表(DT_RELA)等关键数据,供动态链接器(如 ld-linux.so)在加载时解析依赖、绑定符号并完成内存地址修正。是程序运行时动态链接的核心枢纽。
图16 动态节
5.4 hello的虚拟地址空间
使用edb打开hello文件,从数据转储窗口观察hello加载到虚拟地址的情况,查看各段信息。
图17 edb打开hello可执行文件
如text段,由5.3.2可得,.text的起始虚拟地址为0x4010f0
在edb中查询地址可以得到如下图的结果。
图18 数据转储窗口text段
其他段以此类推,都可以在edb中查找到对应的信息。
5.5 链接的重定位过程分析
使用命令objdump -d -r hello > hello1.asm生成反汇编文件hello1.asm
图19 查看hello可执行文件反汇编结果
5.5.1分析hello与hello.o区别
与第四章中hello.o的反汇编结果hello.asm进行比较,发现hello的反汇编代码与hello.o的反汇编代码在结构和语法上基本相同,但hello的反汇编代码多了很多内容,不同之处如下:
链接后的反汇编文件hello1.asm中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入到可执行文件中。
图20 hello1.asm部分截图
函数调用指令call的参数发生变化。
链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。
图21 函数调用变化
(3)跳转指令参数发生变化
在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
图22 跳转指令参数变化
5.5.2重定位过程
重定位由两步组成:
(1)重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的聚合节。然后链接器将运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。至此程序中每条指令和全局变量都有唯一的运行内存地址。
(2)重定位节中的符号引用。这一步中链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目的数据结构。
(3)重定位过程地址计算方法伪代码如下:
foreach section s
{
foreach relocation entry r
{
refptr = s + r.offset;
if (r.type == R_X86_64_PC32)
{
refaddr = ADDR(s) + r.offset;
*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr);
}
if (r.type ==R_X86_64_32)
*refptr = (unsigned) (ADDR(r.symbol) + r.addend);
}
}
5.6 hello的执行流程
5.6.1 执行流程
图23 hello执行流程
(1)开始执行:_start、_libe_start_main
(2)执行main:_main、printf、_exit、_sleep、getchar
(3)退出:exit
5.6.2子程序名和地址
程序名 程序地址
_start 0x4010f0
_libc_start_main 0x2f12271d
main 0x401125
_printf 0x4010a0
_sleep 0x4010e0
_getchar 0x4010b0
_exit 0x4010d0
5.7 Hello的动态链接分析
动态链接的基本思想是将程序的链接过程推迟到程序运行时进行,而不是在编译时完成。这种方法使得程序可以在启动时加载所需的动态链接库(如.so文件),而不必将库的代码在编译时静态地链接到程序中。通过动态链接,多个程序可以共享同一个库的实现,减少内存占用和磁盘空间使用,并且使得库的更新和维护更为灵活和简便。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。延迟绑定是通过GOT和PLT实现的,根据hello.elf文件可知,GOT起始表位置为0x404000。
图24 GOT起始表位置
GOT表位置在调用dl_init之前0x404008后的16个字节均为0。
图25 调用dl_init前GOT表位置内容
调用了dl_init之后内容发生改变,如下图:
图26 调用dl_init后GOT表位置内容
对于变量而言,利用代码段和数据段的相对位置不变的原则去计算正确地址。
对于库函数而言,需要plt、got合作。plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。接下来执行程序的过程中,就可以使用过程链接表plt和全局偏移量表got进行动态链接。
hello在动态连接器加载前后的重定位是不一样的,在加载之后才进行重定位。
5.8 本章小结
本章介绍链接过程,首先阐述了链接的概念作用,给出链接指令。研究了可执行目标文件hello的ELF格式和可执行文件hello的反汇编代码,并用edb查看了虚拟地址空间内容,之后依据重定位条目分析了重定位的过程,并借助edb研究了程序中子程序的执行流程,最后用edb查取虚拟内存,分析了动态链接的过程。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程是操作系统中程序的一次执行过程,也是系统进行资源分配和调度的基本单位。每个进程拥有独立的地址空间、代码、数据以及其他资源,能够独立运行,并与其他进程并发执行。每个进程由操作系统分配唯一的进程标识符(PID)。
进程的经典定义是一个执行程序中的实例,每次用户通过向shell输人一个可执行目标文件的名字,运行程序时,shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
每个进程都有一个独立的地址空间,并由操作系统通过进程控制块(PCB)来管理。PCB 内部存储了进程的状态(就绪、运行、阻塞等)、优先级、资源分配情况以及进程标识符(PID)等信息。换句话说,进程是操作系统进行资源分配和调度的基本单位,是应用程序执行时动态产生的实体。
6.1.2 进程的作用
进程是操作系统进行资源分配和调度的基本单位,它为程序运行提供独立的执行环境,确保内存、CPU等资源的隔离与安全,并支持多任务的并发执行。通过进程,操作系统能够同时运行多个程序,实现用户与系统的交互,同时通过进程间通信(IPC)机制协调不同任务,保障系统的稳定性与效率。简而言之,进程是程序动态执行的载体,是操作系统实现功能和管理计算资源的核心实体。
6.2 简述壳Shell-bash的作用与处理流程
Shell-Bash是用户与操作系统内核之间的核心接口,其作用主要包括命令解释与执行、环境管理和自动化任务处理。它接收用户输入的命令(交互式或脚本形式),解析并转化为系统调用,最终由内核执行底层操作。Bash还支持变量、条件判断、循环等编程特性,能够编写脚本实现复杂逻辑,同时管理进程、I/O重定向和管道,协调多个程序的协作。
处理流程可分为以下步骤:
1.读取输入:从终端或脚本读取命令字符串。
2.解析与扩展:分割命令为参数,处理通配符(*)、变量替换($VAR)、命令替换($(cmd))等语法扩展。
3.执行命令:若为内置命令(如cd),直接由Shell处理;若为外部程序,则创建子进程并通过exec加载执行。
4.控制流程:处理管道(|)、重定向(>/<),管理前后台任务,等待子进程结束并返回状态码。
5.反馈结果:输出执行内容或错误信息,更新环境状态(如当前目录)。
6.3 Hello的fork进程创建过程
首先用户在shel1界面输入指令,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。只有当出现错误时,execve才会返回到调用程序。execve函数启动加载器,映射虚拟内存,进入程序入口后,程序开始载入物理内存,然后进入main函数。main 函数运行时,用户栈的结构如下图所示。
图27 main函数运行时用户栈图示
栈底的是参数和环境字符串,往上是null结尾的指针数组,指向的是环境变量字符串,全局变量environ指向这些指针中的第一个envp[0]。环境变量指针数组后的是argv数组,指向的是参数字符串。栈的顶部是系统启动函数libc_start_main的栈帧,之后是为main函数分配的栈帧。
6.5 Hello的进程执行
hello程序在运行时,进程提供给应用程序的抽象有:(1)一个独立的逻辑控制流,好像我们的进程独占地使用处理器;(2)一个私有的地址空问,好像我们的程序独占地使用CPU内存。
从Shell执行hello程序时,会先处于用户模式运行。在运行过程中,由内核不断进行上下文切换,配合调度器,与其他进程交替运行。如果在运行过程中收到了信号,那么就会陷入到内核中进入内核模式运行信号处理程序,之后再进行返回。
6.5.1 逻辑控制流
如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称为逻辑流。一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。
6.5.2 时间片
一个进程执行它的控制流的一部分的每一时间段叫做时间片。多任务也叫做时间分片。
进程时间片操作系统分配给每个进程在CPU上的处理时间。由于我们知道单核CPU某一时刻只能执行一个进程,所以想要多个进程同时进行就需要将每个进程划分为多个时间片,每次只执行某个时间片,且中间挂起的时间很短,这样就能近似地处理为所有进程同时运行的一个状态。
6.5.3 上下文切换
操作系统内核使用一种称为上下文切换的叫高层形式的异常控制流来实现多任务。内核为每一个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需状态。
6.5.4 用户态和内核态
用户态顾名思义就是用户能够进行操作的系统状态,内核态指的就是由计算机系统内核进行操作的一个状态。hello程序最开始运行是在用户态下,此时用户可以通过键盘向程序发送命令,一般来说,只有在异常处理和信号处理的时候才会切换到内核态。
处理器通常使用某个控制寄存器中的一个模式位来提供这种功能。当设置了模式位时,进程就运行在内核模式里。一个运行在内核模式的进程可以执行指令集中的所有指令且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,也不能直接引用地址空间中内核区内的代码和数据。
6.5.5上下文信息
上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。hello程序执行过程中,在进程调用execve函数后,进程就为hello程序分配新的虚拟地址空间,开始时程序运行在用户模式中,调用printf函数输出,之后调用sleep函数,进程进入内核模式,运行信号处理程序,再返回用户模式,运行过程中,cpu不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用cpu,实现进程的调度。
6.6 hello的异常与信号处理
6.6.1 异常
异常可以分为四类:中断、陷阱、故障和终止。
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
6.6.2信号
信号是一种高层的软件形式的异常,允许进程和内核中断其他进程。信号可以被理解为一条小消息,它通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件,它提供了一种机制,通知用户进程发生了这些异常。
6.6.3 异常的处理方式
图28 异常处理方法
6.6.4 运行结果及相关命令
(1)正常运行状态
在程序正常运行时,打印10次提示信息,以输入回车为标志结束程序,并回收进程。
图29 hello正常运行截图
(2)不停乱按
在程序执行过程中乱按所造成的输入均缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),hello结束后,stdin中的其他字串会当做Shell的命令行输入。
图30 hello运行时瞎按结果
(3)按回车
共输入九次回车,最后一次回车被getchar()读入作为程序结束标志,前八次则会输出出来,程序结束后产生八个换行符,如下图所示。
图31 hello运行时按回车结果
- 按Ctrl-Z
按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。
图32 hello运行时按Ctrl-Z结果
- 按Ctrl-C
按下Ctrl + C,Shell进程收到SIGINT信号,Shell结束并回收hello进程。
图33 hello运行时按Ctrl-C结果
(6)Ctrl+Z后ps命令
对hello进程挂起后由ps命令查看,发现hello进程确实被挂起而非被回收,如下图所示。
图34 Ctrl + Z挂起后ps命令执行结果
(7)Ctrl+Z后jobs命令
对hello进程挂起后在Shell中输入jobs,显示后台挂起作业hello,其job代号为1,如下图所示。
图35 Ctrl + Z挂起后jobs命令执行结果
(8)Ctrl+Z后pstree命令
对hello进程挂起后在Shell中输入pstree命令,可以将所有进程以树状图显示,如下图所示。
图36 Ctrl + Z挂起后pstree命令执行结果
(9)Ctrl+Z后fg命令
对hello进程挂起后,输入fg可以让挂起的作业继续执行,且是从上一次挂起处继续执行(挂起前输出了三次,fg后输出了七次,加起来正好共十次),如下图所示。
图37 Ctrl + Z挂起后fg命令执行结果
(10)Ctrl+Z后kill命令
对hello进程挂起后,发送SIGKILL信号,杀死hello进程,如下图所示。杀死后再运行fg显示进程已被杀死,验证了进程被kill终止。
图38 Ctrl + Z挂起后kill命令执行结果
6.7本章小结
本章介绍了进程的概念及作用,然后分析shell创建hello子进程的过程及hello的进程调度,并介绍了执行过程中的异常和信号处理。最后结合异常和信号的概念,研究了hello运行时不同的异常和命令,针对不同的shell命令,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利用页表来实现从虚拟地址到物理地址的翻译。
图39 页式管理
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位CT(标记位)、s位CI(组索引) 和b位CO(块偏移),根据CI寻找到正确的组,根据CO找到正确的块内位置,依次与每一行的数据比较,有效位有效且标记位一致则命中。如果命中,直接返回想要的数据。如果不命中,就依次去L2、L3、主存判断是否命中,命中时将数据传给CPU同时更新各级cache的储存。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_ struct、区域结构和页表的原样副本。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任何一个。后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域。为新程序的代码、数据、.bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,.bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
(3)映射共享区域。hello程序与共享对象1ibc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器。execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
如果程序执行过程中发生了缺页故障,则内核调用缺页处理程序。处理程序执行如下步骤:
(1)检查虚拟地址是否合法,如果不合法则触发一个段错误,终止这个进程。
(2)检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。
(3)两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。
图40 缺页处理
7.9本章小结
本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理, 在指定环境下介绍了虚拟地址VA到物理地址PA的转换、物理内存访问,分析了 hello进程fork时的内存映射、hello进程、execve时的内存映射、缺页故障与缺页中断处理等,对hello的存储管理有了更为深入的理解。
结论
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 2023111813 鲁永哲 17990070249 1。
6.创建进程。终端判断输入的指令不是shell内置指令,于是调用fork函数创建一个新的子进程。
7.加载程序。shell调用execve函数,启动加载器,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数。
8.执行指令:CPU为进程分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。
9.访问内存:MU将程序中使用的虚拟内存地址通过页表映射成物理地址。
10.信号管理:当程序在运行的时候我们输入Ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
11.终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构。
感悟:
本文详细分析了计算机在生成hello可执行文件的预处理、编译、汇编、链接、进程管理等整个生命周期。通过探讨其原理和方法,演示其操作和结果,阐述计算机系统的工作原理和体系结构,我更深入地理解和掌握C语言程序的编译和执行过程。真正出色的系统设计需要在简洁性、扩展性与可维护性之间找到微妙平衡,同时在追求高性能与安全性时不断探索新的可能性。
附件
所用文件及其作用见下表。
文件名 | 功能 |
hello.c | 源代码 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello | 可执行文件 |
hello.elf | hello.o的ELF文件格式 |
hello1.elf | hello的ELF文件格式 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello1.asm | 反汇编hello可执行文件得到的反汇编文件 |
表2 附件表
参考文献
- Randal E.Bryant David R.O'Hallaron. 深入理解计算机系统(第三版)机械工业出版社,2016.
- 王道论坛 2025年操作系统考研复习指导 .电子工业出版社,2024
- https://blog.csdn.net/wangguchao/article/details/109002488
- linux2.6 内存管理——逻辑地址转换为线性地址(逻辑地址、线性地址、物理地址、虚拟地址) - 刁海威 - 博客园
- https://www.luozhiyun.com/archives/705
- https://blog.csdn.net/qq_40765537/article/details/105940800