计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机与电子通信
学 号 2023112374
班 级 23L0507
学 生 何一飞
指 导 教 师 史先俊
计算机科学与技术学院
2025年5月
本文以经典的“Hello World”程序为例,详细探讨了一个C语言程序从源代码到最终执行的完整生命周期。通过分析程序的预处理、编译、汇编、链接、进程管理、存储管理和IO管理等关键环节,深入揭示了计算机系统在程序执行过程中的底层机制和实现原理。
在预处理阶段,程序通过插入系统头文件内容和宏定义处理,生成扩展后的中间文件。编译阶段将预处理文件转换为汇编代码,展示了高级语言到低级指令的转换过程。汇编阶段进一步将汇编代码翻译为机器语言,并生成可重定位目标文件。链接阶段通过合并库函数和重定位操作,生成可执行文件。随后,程序通过Shell的进程管理机制被加载执行,涉及虚拟地址空间映射、动态链接和异常处理等复杂过程。此外,本文还探讨了程序在内存中的地址转换机制,包括逻辑地址到线性地址的段式管理、线性地址到物理地址的页式管理,以及多级Cache对内存访问的优化作用。
通过这一完整流程的分析,本文不仅展示了计算机系统各组件如何协同工作以支持程序的运行,还为读者提供了对程序底层行为的深入理解。本文的研究方法结合了理论分析与实践验证,使用Ubuntu环境下的工具链(如GCC、GDB、readelf等)进行实验,确保了结论的准确性和可复现性。
关键词:程序生命周期;计算机系统;进程管理;存储管理;动态链接
目 录
第1章 概述
1.1 Hello简介
Hello是程序员的第一个程序。Hello从代码到完整运行经历着非同寻常的过程。具体可以分为Program to Process阶段和Zero to Zero阶段。
在program阶段,Hello经过预处理、编译、汇编、链接,完成可执行文件的生成,在通过shell的进程创建,execve加载,内存映射,动态链接,完成对Hello的进程创建,并加入内存管理。
在process阶段,经过硬件和OS相互协作,完成了从cpu的取指译码执行,到时间片改变的上下文切换,虚拟内存管理,缓存索引存储,缺页处理,IO调用和异常处理等,最终完成了程序的process阶段。
而zero阶段为进程的终止阶段和系统的进程状态归零阶段。他会将main函数返回后调用exit函数,进行资源回收,进程回收,释放内存,抹除系统痕迹,仅留下可能的shell输出记录。
这不就是人的一生吗。
本文所使用的Hello代码源码如下:
#include <stdio.h> |
#include <unistd.h> |
#include <stdlib.h> |
int main(int argc,char *argv[]){ |
int i; |
if(argc!=5){ |
printf("用法: Hello 2023112374 何一飞 13660220635 0!\n"); |
exit(1); |
} |
for(i=0;i<10;i++){ |
printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]); |
sleep(atoi(argv[4])); |
} |
getchar(); |
return 0; |
} |
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
Dell服务器
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 48 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 16
L1d: 1 MiB (16 instances)
L1i: 1 MiB (16 instances)
L2: 8 MiB (16 instances)
L3: 256 MiB (16 instances)
GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403-git
Linux DELL 6.8.0-54-generic #56-Ubuntu SMP PREEMPT_DYNAMIC Sat Feb 8 00:37:57 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
1.3 中间结果
文件名 | 作用 |
Hello.c | 源代码,用于分析逻辑和生成后续文件 |
Hello.i | 预处理文件,可以分析预处理阶段结果 |
Hello.s | 编译文件,可以提供汇编语言文件 |
Hello.o | 二进制文件,可以反汇编分析与链接 |
Hello | 执行文件,可以调试并查看进程执行 |
1.4 本章小结
本章介绍了hello程序的P2P过程和O2O过程,并介绍了介绍了本计算机的硬件环境、软件环境、开发工具与工具、中间结果文件的作用与名称。
第2章 预处理
2.1 预处理的概念与作用
预处理是指预处理器根据以字符#开头的命令,修改原始的C程序。如#include<stdio.h>将告诉预处理器读取stdio.h的内容并将他直接插入程序文本中。结果得到另一个C程序,以.i作为文件扩展名。宏定义等也将在预处理阶段被完成赋值。预处理器不会对头文件中的内容做任何计算或转换,只是简单地复制和替换。
2.2在Ubuntu下预处理的命令
图1 Ubuntu下预处理的命令与.i文件
2.3 Hello的预处理结果解析
图2 hello.i顶部插入信息
图3 hello.i底部插入信息
预处理器提前读取hello.c中的#开头的命令,找到了系统的库文件stdio.h,unistd.h,stdlib.h三个头文件,将其中的内容直接插入到程序文本,并插入对类型等的定义,将源代码保留到最后,不改变源代码的内容。并处理所有的宏定义。
2.4 本章小结
预处理阶段将hello.c通过插入系统头文件内容和其中的各种定义,整合成hello.i预处理文件。本章讲述了预处理的概念与作用,并以hello.c为例演示了Ubuntu下的预处理过程和结果。
第3章 编译
3.1 编译的概念与作用
编译是由编译器将文本文件hello.i翻译成文本文件hello.s。其包含汇编语言程序,包含所写的函数的定义。每条语句都以汇编语言格式描述低级机器语言指令。它为不同的高级语言的不同编译器提供了通用的输出语言。
3.2 在Ubuntu下编译的命令
图4Ubuntu下编译命令与.s文件
3.3 Hello的编译结果解析
对于hello的编译结果,我们将结合源码、C语言的数据与操作进行分析,将hello.s中出现的数据与操作一一详解:
图5 C语言的数据与操作
图6 hello.s文件上半部分
由hello.s文件上半部分和hello.c源码结合分析可得出如下结果。
3.3.1数据、赋值(常量、局部变量、类型,“=”号赋值)
- 对于字符型常量和数字常量如printf中的打印格式,printf中的字符串,源码中的”5”等,汇编语言将直接将其替换。
- 对于在函数中申请的局部变量,如main函数中的i变量,汇编语言将其放入堆栈中进行调用,并使用mov语句进行赋值。
- 对于不同的类型,编译器将为其分配不同的空间,例如hello.c中的i变量为int类型,占四个字节,则编译器为其分配了四个字节,即图中的-4(%rbp)。(注意:-4(%rbp)代表去到rbp寄存器所指的位置再减去4个字节的地方,对其中的数据进行操作,前面的-4并不代表分配的是四个字节,而是因为其距离下一个将要被分配的堆栈地址为四个字节,即距离rbp寄存器所指向的栈顶为四个字节。)
3.3.2数组
- 对于输入的数组变量,编译器将其放入堆栈中的一片连续的内存中,如hello.c中输入的四个数组元素argv[1],argv[2],argv[3],argv[4],直到要调用的时候才从堆栈中取出放入寄存器以便调用。
- 关系操作中,编译器将原本的if(argc!=5)的不等于判断改变为cmpl+je的等于判断和条件跳转格式,便于程序执行。
3.3.3函数调用
- 参数传递时先将参数取出在寄存器中,便于跟随函数进行调用使用,并将现有的寄存器中值压栈到堆栈中。
- 使用call命令对函数进行调用。编译器只将函数名进行了书写,并未直接对函数位置进行跳转。
图7 hello.s文件下半部分
3.3.4算数操作
- 对于变量的算数操作如源码中的i++操作,编译器将使用addl命令,对堆栈或寄存器中的变量进行加法操作,所加的操作数由源代码提供或在寄存器中保存。
3.3.5数组索引
- 对于在数组中的元素,编译器在定位该元素时将先定位数组的首地址,在由数组下标进行偏移计算。具体在堆栈中地址的计算公式可如下参考:
数组元素地址=数组首地址+数组下标*数据类型大小
因为数组中存放的为char*类型的地址,而hello.c运行在64位系统中,每个地址占用8个字节,所以数据类型大小为8,若要索引第四个元素,如图7中的最上方,则先定位数组首地址-32(%rbp),再在addq命令中加上偏移4*8=32,即为该元素的地址。
3.3.6控制转移:
- 对于if函数的判断,编译器将其变为cmpl和jle的条件转移语句,判断是否满足并进行跳转
3.3.7函数返回:
1、函数通过ret命令返回,因为此时没有要弹栈的内容,则无需进行弹栈等操作,直接返回。
3.4 本章小结
本章使用编译命令,生成了hello.s的编译文件,其包含汇编语言程序,包含所写的函数的定义。本章进行了详细的编译文件内容分析,对C语言的数据与操作在编译文件中的形式进行了详解,将C语言代码和编译文件对照分析,
第4章 汇编
4.1 汇编的概念与作用
汇编器将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o为二进制文件,无法直接进行查看。若直接在文本编辑器中查看将只能查看到一堆乱码。汇编将文件变为二进制文件,有利于计算机的处理与分析。
4.2 在Ubuntu下汇编的命令
图8 汇编命令与.o文件
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
图9 elf头上半部分
Elf头标识了生成文件信息,用于帮助链接器语法分析。其中包括elf头的大小、目标文件类型、可运行架构等。其中部分信息有助于我们理解后续内容,我们在此做详细分析:
- 文件类型:可重定位文件
可重定位文件是在链接阶段可用于链接的文件,其中的数据代码的各个区段可以被链接器重定位,将地址替换。
- 机器类型:
机器类型代表可以在什么架构上的机器运行。如x86-64、ARM64、AMD64等。可重定位文件只能在相对应的运行架构上运行。
- 程序入口点
因为程序尚未链接,尚未重定位,所以程序的入口点为0,即在文件开头就进入。
- 段
- 代码区段:代码区段中放置已编译的程序的机器代码
- 重定位区段:一个.text节中位置的列表,当链接器把目标文件和其他文件组合时,需要修改这些位置,如调用外部函数或者引用全局变量指令。调用本地函数指令不需要修改,可用相对寻址。可执行目标文件中并不需要重定位信息。
- 数据区:已初始化的全局和静态C变量。局部的C变量在运行时保存在栈中。
- .bss区:未初始化的全局和静态C变量,所有被初始化为0的全局或静态变量。该节并不占据实际空间,只是一个占位符。
- 只读数据区:存放只读数据,如printf中的格式串和switch语句的跳转表
图10 elf头下半部分
(接上文)
-
- 符号表:存放在程序中定义和引用的函数和全局变量的信息,节名称和位置。不包含局部变量的条目,和编译器中的符号不同。
- 重定位区内容
重定位区内容包括偏移、偏移类型、偏移结果。重定位类型主要包括两种,即重定位PC相对引用(R_X86_64_PC32)和重定位绝对引用(R_X86_64_32),图中出现的R_X86_64_PLT32,链接器仍然将使用R_X86_64_PC32计算来修改重新定位目标。
- 符号表内容
- 符号表中包含该重定位文件所定义的能被其他模块引用的全局符号、全局链接器符号对应于非静态的C函数和全局变量
- 由其他模块定义并被模块所引用的全局符号,即外部符号
- 植被模块定义的局部符号,如static属性的C函数和全局变量。
- Name为符号表的字节偏移,指向符号的字符串名字。Value是符号的地址。对于可重定位模块来说,value是距离定义目标的节的起始位置的偏移。
- Type为数据或函数的类型
- Bind表明数据为本地数据还是全局数据。
- Ndx代表特殊的伪节,其中ABS代表不该被重定位的符号,UND代表未定义的符号。。NDX=1为.text节,Nex=5表示.rodata节。
4.4 Hello.o的结果解析
为更详细研读hello.o,我们使用反汇编命令objdump -d -r hello.o分析hello.o的反汇编,并与第三章的hello.s进行对照分析,并在图中表明区别。
图11 hello.o反汇编与hello.s区别
hello.o的反汇编结果与hello.s的区别主要有以下方面:
图12 hello.o反汇编的最左侧增加了代码相对于main函数的位置
- 增加了汇编语言代码的相对位置在最左侧。代表的是当前指令到main函数的相对位置。便于在链接时进行绝对位置的重定位。
图13 hello.o反汇编的数字都更改为16进制
- 汇编代码中原本十进制的操作数全部更换成十六进制表示,便于机器所识别。
图14 hello.o反汇编增加了重定位描述
- 在重定位的位置表明重定位的方式和最终结果,便于链接器进行重定位。
图15 在地址计算时增加计算注释
- 在地址计算时增加计算注释。
图16 在调用函数时通过main函数偏移来调用
- 分支转移时的最终地址用相对main函数的偏移来进行,而不是L1L2等标识位。
4.5 本章小结
汇编器将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中。本章对汇编器的概念、作用,其生成的.o文件进行了详细分析,并进行反汇编与先前生成的编译文件进行对比,详细介绍了其不同之处,并针对elf头内容进行详细解析。
第5章 链接
5.1 链接的概念与作用
由于hello程序中调用了头文件中的函数,需要前往标准库中链接。如printf函数存在于名为printf.o的预编译好的目标文件中,为标准库函数,需要通过链接的方式合并到hello.o程序中,得到可执行目标文件,可执行目标文件可以被加载到内存中,由系统直接执行。
5.2 在Ubuntu下链接的命令
图17 Ubuntu链接C语言标准库和产生的可执行文件
5.3 可执行目标文件hello的格式
本节我们将分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息,并与先前汇编下的hello.o的elf作对比,突出链接所产生的变化。
图 18 hello的部分elf头截图
在可执行文件hello中,elf头明显长了许多。与可重定位目标文件相比,此时的elf头有以下不同:
图19 文件类型更改为可执行文件
- 类型更改,在链接后,其文件类型更改为可执行文件,而非可重定位目标文件。
图20 程序的入口处更改
- 程序入口处更改。先前程序入口因为没有链接,不知道总的可执行文件共有多长,这段代码又会放在哪里,所以无法确定程序的入口位置,仅仅设定为0x1。链接后程序的各段头的起始位置也进行了更改。这是最后一次更改,更改的结果已经可以直接投入内存中进行执行。
图21 反汇编增加了汇编时没有的段
- 在段中增加了许多汇编时没有的段,例如动态链接的interp段,可快速查看符号表的hash段等,用于动态链接和可执行文件的正确执行。
图22 hello的elf中地址和偏移截图
- 在段中,.text段等各段的地址和偏移都和汇编阶段的elf头不同。例如.text的address在汇编阶段为0,但在链接后变成了0x4010f0,偏移也从汇编时的0x40更改为可执行文件中的0x10f0,这是因为链接后各段地址都进行了重组和偏移,整合成了可执行文件中的段,所以其起始地址和偏移都有相应改变。
图23 hello的elf中的符号表
图24 符号表中增加动态链接运行时的符号表
- 符号表中增加有关动态链接运行时的符号表,便于加载进入内存后进行动态链接。
- 符号表中将链接的函数后标明了需要链接的库的名称,便于动态链接。
图25 增加了许多其他库的符号
- 符号表中也增加了许多先前没有的名称,是由于链接时增加了许多其他库中的符号。
- 符号表中的value也并不全是0,这是因为链接后文件中的符号会在重定位后重新计算其相对节起始位置的偏移。
5.4 hello的虚拟地址空间
使用gdb/edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图26 hello的虚拟地址空间
由虚拟地址空间可知,代码的可执行部分入口点为0x4010f0,其后紧跟的各段为在elf中显示的段与其对应的虚拟地址范围。由于我们的程序使用了动态链接,其动态链接的库放置在内存中,所以其地址范围为内存中地址。
图27 hello进程的地址范围
由进程的地址范围可知,程序起始地址发我在0x40000,一直到0x405000为止,各段都有相对于起始地址的偏移。而动态链接库所对应的地址范围在内存中,不同的内容所对应的大小不尽相同,偏移大小也由上一个库的大小决定。整个堆栈放置在内存的地址高处。
5.5 链接的重定位过程分析
本节我们将使用objdump -d -r hello 命令分析hello与hello.o的不同,说明链接的过程。并结合hello.o的重定位项目,分析hello中对其怎么重定位的。
图28 hello的反汇编文件
Hello的反汇编文件相比于hello.o的反汇编文件主要区别有以下三点:
图29 代码的相对位置更改为对于整个程序的起始位置
- 每行代码的相对位置改为对于整个程序的起始位置,即main函数的位置加上相对main函数的偏移。
图30 压栈保存必要数据
- 由于链接后插入了其他的程序,则在执行main函数指令之前,由很多代码需要被执行,其中有一些寄存器中保存了必要的数据,则在main函数开头插入了压栈保存的代码。
图31 函数引用方式改变
- 对于函数的引用由原先的相对位置或单纯的函数名改为了在文件中的绝对位置,并且函数不放置在main函数中。
5.6 hello的执行流程
本节我们将使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程的主要函数。通过列出其调用与跳转的各个子程序名或程序地址,帮助读者更快理解程序的加载和动态链接过程。
图32 加载动态链接时的_dl_start函数和其地址
加载hello时,会先进过_dl_start函数进行动态链接的开始。
图33 进入_dl_sysdep_start函数
调用与操作系统相关的_dl_sysdep_start函数,建立生命周期,准备执行动态链接器的工作,加载elf头文件。
图34 结束动态链接准备
图35 进行_start的调用
结束动态链接准备过程,已经链接到动态链接库的对应位置,准备调用_start()函数。
图36 进入_start()函数
图37 开始准备执行main函数
进入_start函数,开始准备执行main函数,在执行main函数后程序即将退出。即进入exit函数。
图38 进入exit函数
执行结束后进入exit函数,准备退出程序。
图39 进入__run_exit_handlers程序
进入__run_exit_handlers程序,快速退出当前程序。
图40 进入_dl_fini函数
进入_dl_fini函数,结束动态链接调用
图41 在结束程序的最后释放IO资源
进入_IO_cleanup程序,将所调用的IO释放,最终完成程序终止。
5.7 Hello的动态链接分析
本节将分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,内存映射、GOT表、符号解析状态的内容变化并截图标识说明。
图42 动态链接前的内存映射
图43 动态链接后的内存映射
由动态链接前后的内存映射可以看出,动态链接后内存映射多出了对于共享库的内存映射区域。
图44 动态链接前的GOT表
图45 动态链接后的GOT表
由动态链接前后的GOT表变化可以看出函数的地址有0或无效值变成了实际的函数地址。
图46 动态链接前的printf信息
图47 动态链接后的printf信息
由动态链接前后的符号解析可以看出,动态链接后printf的信息有原先的未定义到具有指定的实际函数地址。
5.8 本章小结
本章通过阐述链接的概念和作用,深刻分析了链接命令、elf链接头、虚拟地址空间和重定位分析,再全流程跟踪hello的执行过程,最终对比动态链接前后的信息变化,详细分析了Ubuntu下链接的实际工作。
第6章 hello进程管理
6.1 进程的概念与作用
进程为程序提供一个由程序正确运行所需要的状态组成的上下文,使得程序好像是系统内存中的唯一对象,也好像是程序单独占用处理器。进程是一个执行程序中的实例。每个进程都有自己独立的一块内存空间,一个进程可以有多个线程。每个程序都是运行在进程的上下文中的,上下文由程序正确运行所需要的状态组成,状态包括存放在内存中的代码和数据,栈,寄存器,程序计数器,环境变量和文件描述符的内容。
6.2 简述壳Shell-bash的作用与处理流程
Shell 是 操作系统内核与用户之间的接口,负责接收用户输入的命令,解释并执行这些命令,然后将结果返回给用户。它既是一个 命令解释器,也是一种 脚本编程语言。
Shell的功能:
解析用户输入的命令,调用对应的程序执行。
环境变量管理,维护进程的运行环境(如 PATH、HOME),影响命令的查找和程序行为。
进程控制,启动、终止、暂停进程。
支持作业控制。
脚本编程,支持if、while等函数和其他编程结构,可编写.sh 文件实现自动化任务。
输入输出重定向,重定向标准输入/输出。管道(|)将一个命令的输出作为另一个命令的输入。
通配符与扩展,使用正则表达式进行匹配。
Shell 的处理流程:
读取输入,从键盘或脚本文件读取命令。
解析命令,拆分命令为单词,识别特殊字符。
展开通配符,替换变量。
执行命令,内置命令由 Shell 自身直接执行;外部程序:通过 fork() 创建子进程,exec() 加载程序。
处理重定向和管道
管道:前一个命令的输出作为后一个命令的输入。
重定向:修改标准输入/输出指向文件(如 > output.txt)。
6、返回结果,将命令的输出或错误信息显示到终端。
6.3 Hello的fork进程创建过程
Shell作为一个进程,其通过fork创建子进程hello。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,因此fork后子进程可以读写父进程中打开的任意文件。父进程和创建的子进程最大的区别在于其PID不同。Fork会调用一次返回两次,分别在父进程和子进程中返回。父进程中返回子进程的进程pid,子进程中返回0,用以区别父子进程。
图48 进程地址空间
Shell作为父进程在运行子进程hello后,由于hello为前台进程,则shell被挂起,等待hello运行完毕。
6.4 Hello的execve过程
在子进程中,子进程通过execve在上下文中加载一个新的程序hello。
子进程通过execve传递目标文件名、带参数列表、环境变量列表,开始调用程序。调用程序后,execve通过启动代码设置栈,在当前进程上下文加载并运行新的程序,可能会覆盖当前进程的地址空间。
Execve在加载的过程中会删除已存在的用户区域,将当前进程的虚拟地址用户部分已存在的区域结构(my_area_struct)完全删除,在将新程序的代码、数据、hss和栈区的区域创建新的区域结构,并设置为私有、写时复制的。将共享区域映射到程序的动态链接处,并重新设置程序的PC,将其指向当前代码区域的入口点。
图49 进程上下文切换的剖析
6.5 Hello的进程执行
进程执行时,进程向每个程序提供的上下文让程序好像独占的使用处理器。在进程的执行过程中,程序计数器通过不停增加并发送指令地址而使程序运行下去。形成了进程的逻辑控制流。
每个进程在执行过程的时间段也叫做时间片,若有多个任务则也叫做时间分片。因为程序执行时可以并发执行,让不同程序并行执行。关键点在于进程轮流使用处理器。在一个运行执行到流的一部分时,其他进程可能会抢占处理器,轮到其他进程,而当前进程被挂起。对于每个单独进程来说好似单独使用了一个更慢的处理器。每次停顿后并不改变程序的内存位置和寄存器内容。
程序的切换是由内核或中断决定的。当发生定时器的中断时,内核判定当前进程执行时间足够长,开始进行切换。在进行程序切换时,不同的进程处于不同的用户态,而用户态只拥有用户态的部分资源和权限,在切换进程时需要先挂起当前进程,保存当前进程的上下文,再切换到具有完全资源和权限的核心态,由内核决定切换到哪一个进程。
由于内核执行进程切换的时候需要从磁盘中取数据,需要等待较长时间,则在切换时内核在间歇时间内执行上下文的切换。
由于cpu采用并行执行,则进程执行时会不停切换,直到进程执行完毕退出。
6.6 hello的异常与信号处理
在程序hello执行过程中会很有可能出现异常。异常是在正常不过的事情了。本节将探讨在hello程序执行过程中可能会出现哪几类异常,会产生哪些信号,探究Ubuntu又是怎么处理的。
图50 hello正常执行
上图展示了hello程序正常执行时的截图,可见hello可以进行十次输出。由于源代码中最后为getchar函数,则会导致输入任意字符都可以继续执行。
图51 对hello执行输入任意字符或空格
输入任意字符是中断异常,由IO设备输入。当输入任意字符时,系统检测到了IO的输入,引发异步的中断,并将字符打印到输出,而程序正常执行。
图52 对hello执行挂起操作
当IO设备输入后,系统检测到IO设备的输入,为control+Z,向当前前台进程发送SIGTSTP信号。这时,产生中断异常,它的父进程会接收到信号SIGSTP并运行信号处理程序,使当前进程挂起,可以在终端中查看到进程的执行命令。
图53 查看hello的进程
当进程挂起时,hello进程可以通过PS命令看到,其分配相应pid可以被查询到。
图54 查看hello的工作
当hello挂起时,可以通过jobs查看到hello的工作组和当前工作状态,工作执行的指令。
图55 传递结束进程信号后对hello再次唤起
当hello挂起时,通过kill指令向hello发送信号,发送杀死命令到hello的进程,将hello杀死,并打印工作的运行命令。将hello杀死后再次尝试从后台唤起hello时已经找不到后台运行的指令。
图56 强制结束hello程序
再次运行hello文件,通过键盘IO输入control+C指令,使得向hello所在进程发送SIGKILL信号,将进程通过bash杀死。
图57 查看hello程序执行时的pstree
再次运行hello文件,通过control+Z将hello挂起,并通过命令pstree查看程序运行的树状图,可以发现当前服务器上共有7人正在运行hello程序。
6.7本章小结
本章通过讲述进程的概念和作用,从shell的命令行执行到fork子进程到execve加载程序,分析进程执行的规律,最终到查看程序执行的异常与信号处理,从多方面系统性地讲述了hello程序执行时的进程管理,并在运行时尝试了不同形式的命令和异常,针对不同的shell命令,进程能产生不同响应。
第7章 hello的存储管理
7.1 hello的存储器地址空间
作为在计算机中运行的程序,hello在诞生的那一刻起就有地址空间。由于计算机的迭代和优化,hello在计算机中有以下四种地址,并在运行时不停转换。在此我们将介绍hello存储器的地址空间——逻辑地址、线性地址、虚拟地址、物理地址。
7.1.1逻辑地址
逻辑地址是用户编程或机器语言指令用于指定一个操作数或一条指令的地址。每个逻辑地址有段选择符和偏移量组成。段选择符通过选择描述符表中的内容给出使用的段寄存器。偏移量为从段开始到实际位置之间的距离。段分为代码段,数据段,堆栈段等,分别由CS指向代码段,SS指向栈段,DS指向数据段,其他三个段寄存器ES,GS,FS可以指向任意数据段。
图58 段寄存器的含义
可以看到,段寄存器中.data,.bss段合成数据段,.init,.text,.rodata段合成代码段,栈单独作为栈段。其他辅助段寄存器则指向内存其他位置。
段寄存器其实是数据结构,是一种段表项,能够进行索引,而描述符表也称段表由段寄存器组成,由三种类型,分别为全局描述符表,局部描述符表,中断描述符表。
全局描述符表用于存放系统内每个人物有可能访问的描述符,如代码段数据段等。
局部描述符表用于存放某个进程专用的描述符。
中断描述符表用于存放包含256个中断门、陷阱门和任务门的描述符。
7.1.2线性地址
若地址空间中的整数是连续的,那么这是一个线性地址空间。线性地址空间可以由逻辑地址转换而来。若启用分页,则淳朴将查询页表,将线性地址转换为物理地址。若禁用分页,则线性地址直接作为物理地址使用。
7.1.3虚拟地址
现代处理器使用虚拟寻址。在寻址时,cpu通过生成一个虚拟地址来访问主存,虚拟地址会被送往地址翻译的单元转换为物理地址,再由物理地址前往寻找内存中的地址。虚拟地址是连续的,是一个线性地址空间。
7.1.4物理地址
物理地址是计算机硬件实际使用的地址,用于访问主存与磁盘等单元。是代码等数据实际放置的地方。物理地址可以利用有限的硬件资源,将操作系统高效分配,防止碎片化。所有地址最终都要转换为物理地址进行访问。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址中有段寄存器概念,段寄存器指向内存中的不同段的位置。如CS段寄存器指向代码段,DS段寄存器指向数据段等,其寻址通过段寄存器+偏移完成。
逻辑地址共48为,先通过上16位找到段寄存器,通过下32位作为段内偏移量。
通过段选择符中的TI位(第二位)为0则表示是使用全局描述符表,段寄存器中的TI位为1则表示使用局部描述符表。
再通过段寄存器的索引(第3位到第15位)加上描述符表的首地址,选中描述符表中的段寄存器,即32位的基地址,在加上原先的段内偏移量,形成32位的线性地址。
图59 逻辑地址转换线性地址过程
7.3 Hello的线性地址到物理地址的变换-页式管理
若未启用分页,则线性地址就是物理地址。若启用分页,则要将线性地址通过页表进行线性地址转换为物理地址。
页表将线性地址拆分为三个部分,即页目录索引,用于定位页目录项;页表索引,用于定位页表项;页内偏移,用于定位物理页中的具体字节。
为查询页表,要先从CR3寄存器中获取指向当前进程的页目录(一级页表),在通过页目录索引,找到页目录项(PDE),PDE中包含二级页表中的物理地址。在获取PDE时,应当检查其权限位。再在二级页表中查询到页表项(PTE),其中包含目标物理页的基址。并检查其权限位。最终由物理页的基址+页内偏移,形成最终的物理地址。
但遇到缺页异常时,PTE标记为不存在,MMU会触发缺页异常,操作系统通过磁盘加载缺失的页进入物理内存,更新PTE并重新执行指令。
若使用更多级的页表,会将线性地址拆分成更多的部分,并支持更大的空间。
在使用线性地址到物理地址的变换时,可以使用快表TLB进行加速。TLB是MMU的缓存,可以避免重复查询页表。若TLB命中,则直接返回物理地址,否则继续走完页表的查询流程。
7.4 TLB与四级页表支持下的VA到PA的变换
从虚拟地址转换为物理地址需要通过地址的翻译,才能找到具体的物理地址。
图60 从虚拟地址变换到物理地址
- cpu给出虚拟地址,若用四级页表,假设其为64位系统,则分为36位的虚拟页号(VPN)。
- 先将虚拟页号进入TLB中查询,若TLB中有存储,则直接给出物理页号,和虚拟页内偏移(其实就是物理的页内偏移)组合成为物理地址。
- 若TLB中未存储,则将虚拟页号进入四级页表查询。将36位的虚拟页号分为各9位,进入页表条目(PTE)中查询页表。其中CR3给出一级页表的首地址,再由VPN1找到二级页表首地址,由此循环找到最终的物理页号,再和页内偏移形成物理地址。
7.5 三级Cache支持下的物理内存访问
获取到物理地址后,需要到存储器中访问实际的物理内存。实际的物理内存存放在cache、主存、辅存中,访问时需要逐级访问。
图61 cache的支持访问
对于每个物理地址,cache将其分为标记位(Tag),组索引(index),块内地址三个部分,其中cache有多种相连方式,即全相连,组相连,直接映射三种方式。
当进入一级cache后,会取出组索引找到当前地址所在的cache中的组,并将标记位和cache中存储的标记进行比较。若在这一组中存储有一样的标记位,则代表cache中存储有这个地址所要的数据。直接取出,并返回给cpu。
当一级cache中不含有这样的标记(即标记位和cache中存储的标记不对应),则一级cache中缺失,将进入二级cache中查找,方法与一级cache相同。若二级cache中有,则返回给cpu,并替换一级cache中的对应组中的一块内存区域。若二级cache没有,则再次查询三级cache。若三级cache中也没有,则访问主存。
若当前cache中没有该地址所需要的内容,则在当前地址提供给cpu时,会将当前cache的对应组的一块存储区域清空(驱逐出去),并替换为当前地址的内容。驱逐的方式有最近最少使用驱逐等方式。
7.6 hello进程fork时的内存映射
准确来说是shell进行fork函数,创建一个新的进程,用以运行hello。在fork时,内核为fork的内容创建了当前进程的副本,包括区域结构,虚拟内存,页表等,将两个进程的所有页都标记为只读,区域结构标记为私有的写时复制。当任意一个进程进行读写时,都会进行复制并创建新的页面。
图62 进程的创建和写时复制
7.7 hello进程execve时的内存映射
Shell在创建完成hello的进程后,通过execve命令进行程序加载。此时内存中父进程的区域结构,私有区域(即各段,各节)都被删除,并覆盖上新的程序的各节。
Execve会创建新的区域结构,将区域结构标记为私有写时复制的,并将代码和数据区的虚拟地址映射完成,为.bss区域请求匿名文件并完成虚拟地址映射。
最终设置映射的共享区域,将动态链接完成映射到用户的虚拟地址空间的共享区域内,重置当前进程的程序计数器,将其指向代码的入口点。
图63 execve时进行的内存映射
7.8 缺页故障与缺页中断处理
当程序进行时通过虚拟地址寻找物理内存时,发现该页的页表条目有效位为0或没有该页的页表项,则会引发缺页中断。缺页中断后,MMU会先查看该虚拟地址是否合法,若不合法则引发异常并结束程序。
若合法则选择该内存中的一块内容(牺牲页),并且若原有的内存内容被编辑过,则将原有的内存内容写回到磁盘中,否则直接替换当前内容。并更新PTE。
替换完成后,MMU将重新回到原先的进程,执行导致缺页的指令,此时因为页已经在内存中,则不会导致缺页。
图64 缺页故障的处理
则可以归纳出流程如下:
- cpu发出虚拟地址给MMU。
- 由MMU翻译虚拟地址,给出页表项的地址,并前往主存中寻找页表项。
- 由主存返回页表项给MMU,MMU识别到当前页表项的有效位为0.
- MMU发出异常,执行缺页异常处理程序。
- 异常处理程序确定主存中的牺牲页并写回磁盘。
- 磁盘中调入所需要的页,覆盖牺牲页内容,更新页表项。
- 重新执行导致缺页的指令。
7.9动态存储分配管理
7.10本章小结
本章介绍了在运行hello时所需要的内存和进程的调度问题和地址翻译问题。通过介绍逻辑地址,物理地址,虚拟地址,线性地址,多层次讲述了内存的寻址和使用方式。详细介绍了段、节、页等内容,覆盖cache、页表、内存,涉猎fork,execve等不同内容,有助于读者详细了解在hello执行时的内存和地址转换内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
Hello代码简单,经历可不简单。
代码编写后,使用预处理器进入预处理阶段,加入头文件中内容,实现文本替换、头文件插入、条件编译处理。
预处理完成后经过编译器将代码转换为汇编语言,完成语法分析、语义分析、代码优化。
经过汇编器将汇编语言转换为二进制可重定位目标文件,进行符号解析、指令编码、生成 .text、.data等节。
最后由链接器进行链接,把外部库中的函数链接进入程序,进行符号解析、地址重定位、动态链接,生成可执行文件。
然而仅仅将可执行文件生成并不能完全概括program to process的全过程,进入执行阶段后,Shell 调用 fork() 创建子进程,execve() 加载 hello 到内存,建立虚拟地址空间映射,并进行页表初始化、动态链接库加载等任务。
执行阶段指令按PC(程序计数器)从内存加载,经流水线处理,访问数据时,MMU 通过页表将虚拟地址转换为物理地址。并反映缺页情况,使得OS 从磁盘加载缺失页到内存。
在进程管理时,CPU 分时执行 hello,发生上下文切换,保存/恢复寄存器状态。并相应不同的异常,如SIGINT信号终止进程,Ctrl+Z信号挂起进程。
在存储管理时,要完成Hello的寻址,通过段式管理,CS:IP 等段寄存器参与的译码将逻辑地址翻译成线性地址。再通过页式管理,TLB + 四级页表加速进行线性地址到物理地址的翻译寻址。在Cache 层次也通过L1/L2/L3 Cache 缓存热点数据,减少内存访问延迟。
如此复杂的经历,仅仅为执行一个Hello程序,就是为了执行程序时考虑到不同的情况,应对不同的异常。我不禁感叹于其中的各个阶段分工合作之精密和有条不紊,执行之快速和层次清晰,存储之节省和优化。这也正像是人啊,如此精密的躯体,如此完美而富有遗憾的大千世界,仅仅是一个人执行微不足道的一生罢了。
附件
文件名 | 作用 |
Hello.c | 源代码,用于分析逻辑和生成后续文件 |
Hello.i | 预处理文件,可以分析预处理阶段结果 |
Hello.s | 编译文件,可以提供汇编语言文件 |
Hello.o | 二进制文件,可以反汇编分析与链接 |
Hello | 执行文件,可以调试并查看进程执行 |
参考文献
- 二侠._dl_start_user源码分析(一).优快云.https://blog.youkuaiyun.com/conansonic/article/details/63254223
- ld-linux.so 动态链接器加载流程浅析.https://asuka39.github.io/posts/2024-01-17-ldso/
- angus_monroe.gdb中查看内存方法总结.https://blog.youkuaiyun.com/angus_monroe/article/details/78515887
- ztguang.gdb查看内存地址和栈中的值—查看虚函数表、函数地址.优快云.https://blog.youkuaiyun.com/ztguang/article/details/51015760
- OPEN_GIS.GDB调试(调试的本质)??栈、堆,虚拟地址布局.优快云.https://blog.youkuaiyun.com/open_gis/article/details/11590709
- 二手的程序员.ELF解析05 - hash表.优快云.https://blog.youkuaiyun.com/a5right/article/details/135643061
- Luck66Max.2.3-2.5 进程创建+虚拟地址空间+GDB多进程调试.https://blog.youkuaiyun.com/qq_41581765/article/details/129738970
- beyond702.动态链接库中的.symtab和.dynsym.优快云.https://blog.youkuaiyun.com/beyond702/article/details/50979340
- 深度人工dazed.静态链接ELF文件介绍.优快云.https://zhuanlan.zhihu.com/p/114348061
- dopamine~.优快云发文章教程.优快云.https://blog.youkuaiyun.com/ajisndopamine/article/details/141277123