摘 要
本文详细介绍了一个名为Hello的程序的生命周期。通过对Hello在Linux下的预处理、编译、汇编、链接等过程的分析,揭示了一个程序一生从诞生到执行再到消亡的典型过程。通过分析程序经历的预处理、编译、汇编、链接、进程管理、IO管理、内存分配与回收等一系列复杂的流程,增进对程序运行过程和计算机内部结构的理解。。
关键词:hello;预处理;汇编;编译;链接;运行;I/O;
目 录
第1章 概述
1.1 Hello简介
hello.c程序
P2P:Program to Process
预处理 | 加入头文件,替换宏。 gcc -E Hello.c -o Hello.i |
编译 | 包含预处理,将 C 程序转换成汇编程序。 gcc -S Hello.c -o Hello.s |
汇编 | 包含预处理和编译,将汇编程序转换成可链接的二进制程序。 gcc -c Hello.c -o Hello.o |
链接 | 包含以上所有操作,将可链接的二进制程序和其它别的库链接在一起,形成可执行的程序文件。 gcc Hello.c -o Hello |
程序由一个项目变成一个进程的过程:C语言源程序hello.c在预处理器(cpp)处理下,得到hello.i,通过编译器(ccl),得到汇编程序hello.s,再通过汇编器(as),得到可重定位的目标程序hello.o,最后通过链接器(ld)得到可执行的目标程序hello。在shell中键入运行命令后,shell调用fork函数为其创建子进程。
O2O: From Zero-0 to Zero-0
程序“从无到有再到无”的过程:在经过P2P后,hello已经在进程中运行,当执行到return后看似hello已经结束,其实它变成了一个僵死进程,此时父进程会收到它正常终止的状态信号(经过调用waitpid函数捕获到其终止信号后),shell 父进程负责回收 hello 进程,内核删除相关数据结构,操作系统将最终释放该进程所占的一系列资源,hello结束,hello程序从无到有再到无的这一过程就是020。
1.2 环境与工具
硬件环境:Intel Core i7 x64CPU;16G RAM;512G SSD
软件环境:Windows 11 64位;Vmware Workstation Pro 17;Ubuntu 64位
开发工具:CodeBlocks 64位;VScode 64位;vim+gcc,edb,gdb,objdump
1.3 中间结果
hello.c:源程序文件;
hello.i:修改了的源程序;
hello.s:汇编程序文本文件;
hello.o:可重定位目标程序(二进制文件);
hello:可执行目标程序;
hello.asm:hello的反汇编文件
1.4 本章小结
本章简要介绍了hello的一生(P2P、020的具体过程)、实验环境、实验工具、和中间结果等基本信息。
第2章 预处理
2.1 预处理的概念与作用
(1)概念:
编译器进行实际编译之前,由预处理器(通常称为cpp)对源代码进行的一系列处理。这个阶段主要涉及对源文件中的预处理指令进行解析和执行,这些指令以井号(#)开头,生成*.i文件。
(2)作用:
预处理为进一步编译做准备,对源程序. c文件中出现的以字符“#”开头的命令进行处理,包括宏定义、文件包含、条件编译等,最后将修改之后的文本进行保存,生成. i文件
宏替换:预处理器会处理宏定义(使用#define指令),将宏名替换为它们的值或定义的代码片段。
文件包含:通过#include指令,预处理器可以将指定的头文件内容包含到源文件中,这使得程序员可以重用代码和声明。
条件编译:预处理器执行条件编译指令(如#ifdef、#ifndef、#if、#else、#elif和#endif),允许根据不同的条件包含或排除代码段。
注释处理:预处理器删除源代码中的所有注释,包括单行注释(//)和多行注释(/* ... */),以简化编译器的工作。
行控制:使用#line指令,预处理器可以在生成的代码中指定文件名和行号,这有助于调试和错误报告。
保留编译器指令:#pragma指令用于向编译器发送特定的指令,这些指令在预处理阶段被识别。
代码清理:预处理器还会删除代码中的空白字符(如空格、制表符和换行符),除非它们是宏定义的一部分。
生成预处理后的文件:预处理的结果通常生成一个新的文件(例如.i文件),这个文件随后会被编译器用来生成目标代码。
2.2在Ubuntu下预处理的命令
预处理命令:gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i
-m64:此选项指定编译器生成64位的代码;
-no-pie:此选项禁用位置无关执行(Position Independent Executable,PIE)功能;
-fno-PIC:此选项告诉编译器不生成位置无关代码(Position Independent Code,PIC)
2.3 Hello的预处理结果解析
头文件都被替换了相应的内容
原文件中所对应的注释和多余空白都被删除了
代码行变多
2.4 本章小结
本章了解了预处理的相关概念,并对hello.c文件经行了预处理操作,简要分析了预处理后的hello.i文件
第3章 编译
3.1 编译的概念与作用
(1)概念:
编译器(ccl)将预处理生成的文件hello.i进行一系列的词法分析、语法分析等后翻译成汇编文件文件hello.s。它包含一个汇编语言程序。
(2)作用:
语言翻译:编译器将高级语言编写的源代码转换成机器语言,使得程序能够在特定的硬件平台上执行。
错误检测:编译过程中的语法分析和语义分析帮助发现源代码中的错误,并在编译时报错。
性能优化:编译器通过优化代码,提高程序的运行效率,减少资源消耗。
跨平台:编译器允许开发者编写一次代码,然后在不同的平台上编译,从而实现跨平台运行。
内存管理:编译器在编译过程中会处理程序的内存布局,包括栈的使用和静态变量的分配。
符号解析:编译器负责解析程序中的符号(如变量名和常量),确保它们在链接时能够正确引用。
生成可执行文件:最终,编译器和链接器合作生成可执行文件,这是用户可以直接运行的程序。
3.2 在Ubuntu下编译的命令
编译命令:gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s
3.3 Hello的编译结果解析
编译结果解析:
3.3.1 数据
(1)字符串常量(字符串常量,立即数等)
(2)变量
3.3.2 赋值
3.3.3 算术操作
3.3.4 关系操作
if (argc != 5)
i < 10
3.3.5 数组/指针/结构操作
3.3.6 关系操作和控制转移
关系操作通常与控制转移合并使用,可以看到多处地方用到cmp指令与jmp的系列变式,它通常描述的是程序中的判断if语句以及循环终止条件
3.3.7 函数操作
常常使用call指令进行相关的函数调用,并且函数返回调用ret指令
3.4 本章小结
本章大致介绍了常见汇编指令,重点解释了“hello”程序的编译结果和指令。
第4章 汇编
4.1 汇编的概念与作用
(1)概念:
汇编是指汇编器as将编译后的文件转换生成机器语言二进制程序(即从 .s 到 .o )的过程。
(2)作用:
代码转换:汇编器将汇编语言编写的代码转换成机器语言,生成可执行的二进制指令。这些指令是硬件能够直接执行的最低级代码。
调试和分析:汇编代码可以用于调试和性能分析,帮助开发者理解高级语言代码转换成机器指令后的具体行为。
4.2 在Ubuntu下汇编的命令
汇编命令:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
4.3 可重定位目标elf格式
输入指令:readelf -a hello.o > hello.elf
Eif格式如下:
4.3.1 ELF头:
ELF以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。在ELF头中可以看到目标文件的类型、系统架构信息、节头大小和数量等信息。
4.3.2 节头部表
在Ubuntu中如下:
4.3.3 重定位节
在链接过程中,链接器需要将这些相对地址或符号名转换为实际的内存地址。这个过程称为“重定位”。重定位节存储了所有需要重定位的项的信息,包括它们在文件中的位置、需要重定位的符号以及符号的类型。
elf文件中.rlea.text以及.rela.eh.frame所对应的重定位条目,里面记录着一些需
要重定位的符号puts、exit等以及他们的偏移量。
4.3.4 符号表
可以看到一些符号的偏移量(value)、大小(size)、类型(type)等信息
4.4 Hello.o的结果解析
反汇编命令:objdump -d -r hello.o > hello.asm
反汇编文件中,左侧显示的是机器码,而右侧显示的是有过修改的汇编语言
在数的表示上,hello.s中的操作数表现为十进制,而hello.o反汇编代码中的操作数为十六进制。
在控制转移上,跳转指令变为了关于地址计算的相对偏移地址寻址,并且一些有关的指令后缀(例如b、l)被省略了。hello.s使用.L2和.LC1等段名称进行跳
转,而反汇编代码使用目标代码的虚拟地址跳转。不过目前留下了重定位条目,跳转地址为零。它们将在链接之后被填写正确的位置。
在函数调用上,反汇编代码中用相对于main函数的偏移地址表示函数地址,而不是hello.s中的函数名称。
4.5 本章小结
本章介绍了hello.c程序的汇编过程,hello.o的ELF格式,对比了汇编代码与反汇编代码的区别。
第5章 链接
5.1 链接的概念与作用
(1)概念:
将多个目标文件和库文件组合成一个单一可执行文件(从 hello.o 到hello)的过程。通过链接,开发者能够将分散的代码片段和资源有效地组织起来,形成一个完整的、可执行的程序。
(2)作用:
实现代码和资源的整合,确保程序中的符号引用得到正确的解析和重定位,从而生成一个能够独立运行的程序。链接器在这一过程中会处理符号的冲突,支持动态链接,合并公共的代码段以节省空间,同时还会移除未被引用的代码以优化程序大小。
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.ohello.o/usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
5.3 可执行目标文件hello的格式
命令:readelf -a hello > Hello1.elf
hello的ELF头:
文件的类型由可重定位文件变为可执行文件,程序的入口点、程序开始点、节头表偏移等发生改变
hello的节头表:
节增多,重定位条目和字符表发生了更新。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
其与反汇编代码中的虚拟地址可一一对应。
5.5 链接的重定位过程分析
命令:objdump -d -r hello > Hello.asm
代码量:hello的反汇编代码长度比hello.o大很多,这与链接后生成的hello中加入了hello.c中调用的其他函数(如printf,exit等)有关。
地址:hello反汇编代码中地址为虚拟内存地址,hello.o反汇编代码中为相对偏移地址。这是因为hello无需重定位。
代码内容:hello的反汇编代码中增加了.init和.plt节以及节中定义的函数
重定位条目:hello的反汇编代码无重定位条目。这是因为hello无需重定位。
链接的过程:
编译生成目标文件:源代码被编译器编译成目标文件(.o 文件)。
符号解析:链接器解析目标文件中的符号引用,确定每个符号的定义和声明。
地址分配:链接器为程序中的代码和数据分配内存地址。
合并节区:相同类型的节区(如文本、数据、资源等)被合并。
生成可执行文件:链接器将所有目标文件和库文件合并,生成最终的可执行文件。
重定位的过程:
确定符号地址:链接器确定每个符号在内存中的最终地址。
修改代码引用:链接器更新代码中的地址引用,确保它们指向对的内存位置。
调整数据引用:通过链接器调整数据引用,确保全局变量和常量在内存中正确定位。
生成重定位表:为动态链接生成重定位表,包含运行时需要调整的地址。
完成链接:链接器完成所有重定位,确保程序在加载时能够正确运行。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。
init 0x4004c0
puts@plt 0x4004f0
printf@plt 0x400500
getchar@plt 0x400510
atoi@plt 0x400520
exit@plt 0x400530
sleep@plt 0x400540
_start 0x400550
dl_relocate_static_pie 0x400580
Main 0x400586
libc_csu_init 0x400610
__libc_csu_fini 0x400680
_fini 0x400684
5.7 Hello的动态链接分析
根据节头部表找到GOT表地址,由下图可知其在0x403ff0
_init前的Data Dump:
_init后的Data Dump:
5.8 本章小结
本章主要对linux系统下链接的过程通过在edb调试,终端查看hello的虚拟地址空间,通过对比hello.o和hello的反汇编代码等一系列过程,对重定位,执行流程和动态链接过程进行分析与概述。
第6章 hello进程管理
6.1 进程的概念与作用
(1)概念:
进程是操作系统进行资源分配和任务调度的基本单位,代表一个正在执行的程序的实例。
(2)作用:
进程作为程序执行的实体,提供了程序并发执行的机制,使得多个程序可以同时在计算机上运行。它拥有独立的地址空间,确保了程序间的隔离性和安全性。进程使得资源管理更加高效,因为操作系统可以针对每个进程分配适当的资源,如CPU时间、内存等,并根据进程的优先级和资源需求进行调度。此外,进程的创建、同步、通信和终止等操作,都是操作系统内核管理和调度的,这为应用程序提供了一个稳定和可靠的运行环境,同时也支持复杂的系统功能,如多任务处理、网络通信和用户界面管理。通过进程,操作系统能够实现对计算机硬件的有效控制和对用户需求的灵活响应。
6.2 简述壳Shell-bash的作用与处理流程
作用:
Shell-bash(通常简称为bash)是一种广泛使用的命令行界面(CLI),它为用户提供了与操作系统交互的手段。作为用户与操作系统之间的桥梁,bash允许用户执行各种命令来管理文件系统、启动应用程序、配置环境变量以及控制系统进程等。它的强大之处在于能够通过脚本自动化复杂的任务,提高效率。
处理流程:
命令输入:用户在命令行提示符下输入命令。
命令解析:bash解释用户输入的命令,将其分解为命令本身和相应的参数。
命令执行:如果是内置命令,bash会直接执行该命令。如果是外部命令,bash会在系统路径中搜索对应的可执行文件来执行。
参数传递:将命令所需的参数传递给相应的执行程序。
输出结果:命令执行后的结果会显示在终端,或者重定向到文件等。
错误处理:如果命令执行过程中出现错误,bash会显示错误信息并返回用户。
等待用户输入:命令执行完毕后,bash会等待用户的下一个输入。
6.3 Hello的fork进程创建过程
strace -f ./hello命令可以跟踪名为hello的程序的系统调用。其中,-f选项表示跟踪子进程的系统调用。
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps49.jpg&pos_id=cEisfkd3)
fork是一个系统调用,它通过复制调用它的现有进程(父进程)来创建一个全新的进程(子进程)。子进程几乎是父进程的一个副本,但它拥有自己独立的进程标识符(PID)。
步骤:
系统调用:父进程执行fork()系统调用。
复制地址空间:操作系统创建一个新的进程,复制父进程的地址空间到子进程,包括代码段、数据段、堆和栈。
分配PID:为新创建的子进程分配一个唯一的进程标识符。
返回值:fork()系统调用在父进程中返回新创建的子进程的PID,在子进程中返回0。
独立执行:从这个点开始,父进程和子进程将独立执行。它们拥有各自的执行流,可以执行不同的命令或继续执行相同的程序。
资源分配:尽管子进程是父进程的副本,但操作系统会为子进程分配独立的资源,如文件描述符等。
写时复制:如果父进程和子进程尝试写入相同的内存区域,操作系统将执行写时复制(Copy-on-Write),为写入操作的进程创建该内存区域的私有副本。
6.4 Hello的execve过程
execve函数能够在当前进程的上下文中加载并运行一个新程序,同时携带参数列表argv和环境变量列表envp,但只有当出现错误时才能返回到调用程序,否则该函数调用一次从不返回。
过程:
加载新程序:当 execve 函数被调用时,它会加载指定名称的程序(例如 hello)到当前进程中。
删除现有虚拟内存段:加载新程序之前,execve 会指示加载器删除子进程现有的虚拟内存段,这包括代码段、数据段、堆和栈。
创建新的内存段:接着,execve 创建一组新的虚拟内存段,包括代码段、数据段、堆和栈。这些新的内存段被初始化为零。
内存映射:execve 将虚拟地址空间中的页映射到新程序的页大小的片。这意味着虚拟内存中的页被链接到程序的代码和数据所在的磁盘上的页。
初始化代码和数据:新程序的代码和数据被加载到刚刚创建的代码段和数据段中,这些段现在包含了程序的执行代码和初始化数据。
跳转到入口点:execve 通过跳转到程序的第一条指令或入口点来启动程序的执行。在 Linux 系统中,这通常是 _start 函数的地址。
调用 main 函数:一旦程序的控制权转移到 _start 函数,它将负责设置程序的执行环境,并最终调用 main 函数,从而开始程序的正常执行流程。
程序执行:从 main 函数开始,程序按照编写的逻辑执行,直到遇到结束条件或调用退出系统调用。
6.5 Hello的进程执行
上下文信息:进程通过上下文切换,控制流通从一个进程传递到另一个进程。
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps50.jpg&pos_id=YiNZqtAQ)
刚开始时,进程在Process shell中,并且处于用户模式。之后,进入了内核模式,将相关的变量,栈,环境都设置好,然后切换上下文,切换到Process shell中,这样一来就完成了进程的上下文切换。
进程时间片:进程时间片是操作系统分配给每个进程执行的时间单元。在分时系统中,操作系统将CPU时间分割成多个时间片,并轮流分配给就绪队列中的进程。每个进程在获得CPU并执行时,只能使用一个时间片的时间。时间片用完后,如果进程还需要继续执行,它将被放回就绪队列的末尾,等待下一个调度机会。
用户态与核心态转换
用户态和核心态是进程执行的两种特权级别:
用户态:进程在执行普通程序代码时所处的状态。在用户态下,进程只能访问受限的资源和执行受限的操作,以防止对系统稳定性和安全性造成损害。
核心态:当进程需要执行系统调用或响应异常和中断时,将切换到核心态。在核心态下,进程可以访问所有硬件资源,并执行所有指令,包括那些管理内存、处理I/O操作和创建新进程等特权指令。
进程调度过程
调度队列:操作系统维护一个或多个就绪队列,包含所有准备执行的进程。
调度策略:操作系统根据调度策略(如先来先服务FCFS、短作业优先SJF、轮转调度RR等)选择下一个要执行的进程。
上下文切换:当CPU从一个进程切换到另一个进程时,操作系统会保存当前进程的上下文信息,并加载下一个进程的上下文信息。
时间片管理:操作系统为每个进程分配时间片,并在时间片用尽时进行上下文切换。
优先级调整:根据进程的行为和系统策略,操作系统可能调整进程的优先级,影响其在就绪队列中的位置。
中断和异常处理:在执行过程中,进程可能因系统调用、I/O操作完成或异常情况而触发中断或异常。操作系统将处理这些事件,并在必要时进行上下文切换。
用户态与核心态的转换
系统调用:当进程执行系统调用请求操作系统服务时,从用户态切换到核心态。
中断:硬件设备完成I/O操作时,通过中断信号通知CPU,进程从用户态切换到核心态。
异常:进程执行非法操作(如除零错误)时,触发异常处理,从用户态切换到核心态。
返回用户态:系统调用、中断或异常处理完成后,操作系统将控制权交回给进程,从核心态切换回用户态。
6.6 hello的异常与信号处理
正常运行:
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps51.jpg&pos_id=aveEoe19)
Ctrl-Z:发送SIGTSTP信号给前台进程组中的每个进程,进行停止操作,此时发现进程组中hello标识为“已停止”的状态
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps52.jpg&pos_id=Qa3b0aL2)
Ctrl-C:发送SIGINT信号给前台进程组中的每个进程,进行终止操作,同时发现进程组中hello已被回收。
Ctrl-z后可以运行ps:
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps53.jpg&pos_id=CLjzWKgX)
Ctrl-z后可以运行ps jobs pstree fg kill 等命令:
ps:这个命令用来显示当前系统的进程状态。它会列出所有运行中的进程及其详细信息,如进程ID(PID)、启动时间、CPU和内存使用情况等。
jobs:这个命令显示当前终端会话中的作业列表。它列出了由用户在当前 shell 会话中启动的所有作业,包括那些被放到后台的进程。
pstree:pstree 命令以树状图的形式显示进程及其父进程和子进程的关系。这有助于理解进程之间的层级关系。
fg:fg(foreground)命令用于将一个在后台暂停的进程调回到前台继续运行。当您使用 Ctrl-z 暂停一个进程后,可以使用 fg 命令恢复它。
kill:kill 命令用于向特定进程发送信号以终止它。您可以使用 kill 命令后跟进程ID或作业号来结束一个进程。
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps54.jpg&pos_id=wLFVcIZL)
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps55.jpg&pos_id=M5PAr7lC)
6.7本章小结
本章深入探讨了程序的链接过程,解释了其重要性和基本概念。通过分析`hello`程序,展示了目标文件和库文件如何合并成可执行文件,涉及符号解析和重定位。讨论了静态链接与动态链接的区别,以及链接器如何生成可执行文件的结构。最后,通过实例分析了动态链接机制、程序加载、异常和信号处理,为深入学习计算机系统原理打下基础。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址(Logical Address):
逻辑地址也称为“程序地址”或“段内偏移地址”,是程序在编译时生成的地址。它们是相对于程序的段基址(段的起始地址)的偏移量。在段式内存管理中,逻辑地址由两部分组成:段选择符(指向段描述符)和段内偏移量。
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps56.jpg&pos_id=159I0gaj)
线性地址(Linear Address):
线性地址,也称为“平坦地址”或“虚拟地址”,是逻辑地址经过地址转换后生成的。在现代操作系统中,线性地址通常是指虚拟地址空间中的地址,它是一个连续的地址空间,由操作系统管理。线性地址经过内存管理单元(MMU)的转换,映射到物理地址。
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps57.jpg&pos_id=0bePBwJ8)
虚拟地址(Virtual Address):
虚拟地址是进程在执行时使用的地址。它是操作系统虚拟内存管理的一部分,允许每个进程拥有自己的地址空间。虚拟地址隐藏了物理内存的实际布局,提供了一个抽象层,使得每个进程都像是在独立使用整个内存空间。操作系统负责将虚拟地址映射到物理地址。
物理地址(Physical Address):
物理地址是实际内存单元(如RAM中的存储单元)的地址。它们是直接映射到内存芯片上的地址。物理地址是内存管理单元(MMU)转换虚拟地址后得到的,用于访问实际的硬件内存。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址
逻辑地址由两部分组成:段选择符(Segment Selector):它是一个指向段描述符(Segment Descriptor)的索引,段描述符包含了段的基地址和其他属性。段内偏移量(Offset):这是从段开始的字节偏移量,表示要访问的数据在段内的位置。
段描述符:段描述符存储在全局描述符表(GDT, Global Descriptor Table)或局部描述符表(LDT, Local Descriptor Table)中。它包含了段的基地址(Base Address)、段的界限(Limit)和访问权限(Access Rights)等信息。
变换过程
选择段描述符:CPU使用段选择符中的索引来访问GDT或LDT中的相应段描述符。检查权限:CPU检查段描述符中的访问权限,确保当前操作是允许的。计算基地址:从段描述符中提取基地址。应用偏移量:将段内偏移量加到基地址上,得到线性地址。界限检查:CPU检查计算出的线性地址是否在段的界限内,如果超出界限,将引发一个保护故障。
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps58.jpg&pos_id=EhE2uCQV)
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址
线性地址,也称为虚拟地址或虚拟内存地址,是程序在执行时生成的地址。这些地址在没有经过内存管理单元(MMU)转换之前,不能直接用于访问物理内存。
页表(Page Table):页表是一个数据结构,用于存储虚拟地址到物理地址的映射信息。
页目录(Page Directory):页目录是页表结构的顶层,它包含了页表本身的物理地址。
页帧(Page Frame):页帧是物理内存中的一部分,用于存储页表中的页。
变换过程
页目录访问:CPU生成一个线性地址后,MMU使用该地址的特定部分(通常是最高位)作为页目录中的索引,来查找对应的页表项(Page Table Entry, PTE)。
页表项检索:通过页目录项,MMU找到相应的页表,并使用线性地址中的下一部分作为索引来检索页表项。
页帧号确定:页表项包含了页帧号(PFN, Page Frame Number)和一些状态信息(如存在位、权限等)。页帧号用于在物理内存中定位页帧。
物理地址计算:一旦MMU获取了页帧号,它就可以与线性地址中的页内偏移量(Page Offset)组合起来形成完整的物理地址。物理地址 = (页帧号 × 页大小) + 页内偏移量。
访问权限检查:在访问物理内存之前,MMU还会检查页表项中的访问权限,确保进程有权访问该页。
缓存和TLB:现代CPU通常配备有转换后备缓冲器(TLB, Translation Lookaside Buffer)和缓存系统,用于加速地址转换过程。
缺页处理
如果线性地址对应的页不在物理内存中(缺页),MMU会触发一个缺页异常。操作系统将处理此异常,从磁盘中读取所需的页到物理内存中,并更新页表项。这个过程对用户程序是透明的。
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps59.jpg&pos_id=wYJD12Uo)
7.4 TLB与四级页表支持下的VA到PA的变换
TLB(Translation Lookaside Buffer)
TLB是一个硬件缓存,用于存储最近进行过的虚拟地址到物理地址的转换条目。它的目的是减少对页表的访问次数,从而加快地址转换速度。以下是TLB的工作流程:
地址转换请求:当程序访问一个虚拟地址时,MMU首先检查TLB。
TLB查找:MMU使用虚拟地址的一部分(通常是虚拟页号VPN)作为索引来查找TLB。
TLB命中:如果找到了对应的条目,即TLB命中,MMU将直接使用TLB中的物理页号(PPN)和页偏移量(PO)来形成物理地址。
TLB未命中:如果没有在TLB中找到对应的条目,MMU将继续在页表中查找。
四级页表
随着虚拟地址空间的增大,传统的两级页表结构可能不足以高效管理内存。四级页表(或多级页表)允许更细粒度的地址转换,适用于具有大地址空间的系统。以下是四级页表的工作流程:
页全局目录(PGD):虚拟地址的最高几位用于索引页全局目录。
页上级目录(PUD):从PGD中得到的条目指向页上级目录。
页中间目录(PMD):从PUD中得到的条目指向页中间目录。
页表(Page Table):从PMD中得到的条目指向实际的页表。
页帧:页表中的条目包含物理页帧号和页偏移量。
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps60.jpg&pos_id=i9wdjAZg)
VA到PA的变换过程
生成虚拟地址:程序生成一个虚拟地址。
TLB查找:MMU首先在TLB中查找虚拟地址的转换条目。
TLB命中/未命中:如果TLB命中,MMU直接使用TLB中的信息形成物理地址。如果TLB未命中,MMU继续在四级页表结构中查找:使用虚拟地址的高阶位索引PGD。从PGD获取PUD的条目。从PUD获取PMD的条目。从PMD获取页表的条目。最后,从页表中获取物理页帧号和页偏移量。
物理地址形成:一旦MMU获取了物理页帧号和页偏移量,它就将它们与虚拟地址的页内偏移量结合起来形成完整的物理地址。
TLB更新:如果TLB未命中,并且页表查找成功后,MMU可能会更新TLB,以便将来的访问可以更快地通过TLB完成。
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps61.jpg&pos_id=G4T0uR7l)
7.5 三级Cache支持下的物理内存访问
物理内存访问过程:
CPU发出内存访问请求:当CPU需要访问数据时,它首先发出一个内存访问请求。
L1 Cache查找:请求首先发送到L1缓存,如果数据在L1缓存中(缓存命中),则直接返回数据。
L2 Cache查找:如果L1缓存未命中,请求会发送到L2缓存,如果数据在L2缓存中,同样直接返回数据。
L3 Cache查找:如果L2缓存也未命中,请求继续发送到更大的L3缓存。
主内存访问:如果三级缓存都未命中(缓存未命中),请求最终发送到主内存(RAM)。此时,内存控制器从物理内存中提取数据。
缓存行加载:从主内存加载数据时,通常不是只加载请求的单个数据项,而是加载一个完整的缓存行(Cache Line)到缓存中。
更新缓存:当数据从主内存加载到缓存后,所有级别的缓存都会根据需要更新,以保持数据的一致性。
数据返回给CPU:加载的数据最终被返回到请求它的CPU核心。
7.6 hello进程fork时的内存映射
复制父进程地址空间:fork() 调用时,父进程的整个地址空间被复制给子进程。这包括代码段、数据段、堆和栈。
创建新的进程描述符:操作系统为子进程创建一个新的进程描述符(通常是一个结构体),包含子进程的状态信息,如进程ID(PID)。
写时复制(Copy-on-Write, COW):在许多现代操作系统中,fork() 调用后,子进程的内存页面最初是与父进程共享的。这意味着,只有当其中一个进程尝试写入页面时,页面才会被复制,从而确保两个进程拥有独立的内存副本。这被称为写时复制。
更新内存映射:子进程的内存映射需要更新,以反映新的进程状态和任何在 fork() 调用后创建的新内存区域。
7.7 hello进程execve时的内存映射
命令行解析:在shell中输入 "hello" 命令后,shell 首先解析这个命令行,确定要执行的程序是 "hello"。
调用 execve():当前进程的代码将控制权交给 execve() 函数。这个系统调用将加载 "hello" 程序并替换当前进程的映像。
删除现有用户区域:execve() 首先删除当前进程的现有用户区域,包括代码段、数据段、堆和栈。这一步是必要的,因为它为新程序的加载腾出了空间。
映射私有区域:代码段 (.text):新程序的机器代码被加载到代码段。数据段 (.data):新程序的初始化全局变量和静态变量被加载到数据段。BSS 段 (.bss):为未初始化的全局变量和静态变量分配空间,通常初始化为零。
映射共享区域:如果新程序需要使用共享库,这些共享区域也会被映射到进程的地址空间中。共享区域允许多个进程共享相同的代码和数据,从而节省内存。
设置程序计数器 (PC)并开始执行程序
7.8 缺页故障与缺页中断处理
缺页故障处理:
暂停当前指令:CPU检测到缺页故障时,会暂停当前正在执行的指令。
触发中断:缺页故障会触发一个中断,将控制权转交给操作系统内核。
保存上下文:操作系统会保存当前进程的上下文,包括寄存器状态和程序计数器。
分析故障地址:内核分析缺页故障的地址信息,确定需要访问哪个物理页面。
页面调度:如果所需的物理页面尚未加载到内存中,操作系统将执行页面调度算法,选择一个页面从磁盘加载到物理内存。
选择牺牲页面:如果物理内存已满,内核会选择一个牺牲页面,如果该页面已修改,则将其写回磁盘。
加载所需页面:操作系统从磁盘读取所需的页面到物理内存中。
更新页表:内核更新页表项,将新的物理页面地址与缺页的虚拟地址关联起来。
重新启动指令:处理完成后,操作系统恢复进程的上下文,并重新启动导致缺页故障的指令。
继续执行:进程继续执行,现在可以访问所需的内存页面,因为该页面已经加载到物理内存中。
缺页中断处理:
捕获中断:当CPU检测到缺页故障时,它生成一个中断信号,操作系统内核捕获此信号。
中断服务例程:内核调用缺页中断服务例程,这是一个专门处理缺页故障的函数。
识别故障地址:中断服务例程通过硬件提供的故障地址信息确定缺失的页面。
磁盘I/O操作:内核请求磁盘I/O操作,从磁盘读取缺失页面的数据。
内存管理:内核管理内存,确保有足够的空间来存储新加载的页面。
更新硬件映射:内核更新内存管理硬件(如页表基址寄存器)来反映新的内存布局。
返回用户模式:中断处理完成后,内核将控制权返回给用户模式下的进程。
重试失败操作:进程恢复执行,并重试最初因缺页而失败的操作。
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps62.jpg&pos_id=oaUr3Sr7)
7.9动态存储分配管理
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用,而空闲块可以用来分配,此时空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放。这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
一般情况下,动态内存管理通过内存分配函数(如`malloc`、`calloc`、`realloc`)和内存释放函数(如`free`)实现。程序可以根据需要动态分配内存,但也需要负责释放不再使用的内存以防止内存泄漏。策略涉及内存分配算法的选择(如首次适应、最佳适应等)、处理内存碎片的方法、使用内存池和缓存机制,以及垃圾回收等。
7.10本章小结
本章中,我们学习了hello在存储过程中几种不同的地址形式(如线性地址,虚拟地址,逻辑地址,物理地址)、intel的段地址管理方式、各种地址的变化过程、fork,execve的内存映射和缺页故障等的处理问题的机制。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
在Linux中,所有的设备都被抽象为文件。这意味着无论是硬盘、键盘、鼠标还是网络接口,它们都被视为文件系统中的一个实体。这些设备文件位于特殊的目录 /dev 下,每个设备文件对应一个具体的设备。
设备管理:unix io接口
Linux提供了Unix I/O接口,这是一组标准的系统调用,用于对设备文件进行操作,就像对普通文件一样。通过这些接口,应用程序可以使用熟悉的文件操作命令,如 open()、read()、write() 和 close(),来执行输入输出操作。
8.2 简述Unix IO接口及其函数
open() | 功能:打开一个文件或设备,创建一个新的文件,或者将现有文件连接到一个文件描述符。 原型:int open(const char *pathname, int flags, mode_t mode); 返回值:成功时返回文件描述符,失败时返回-1。 |
close() | 功能:关闭一个打开的文件描述符,释放与该描述符关联的所有资源。 原型:int close(int fd); 返回值:成功时返回0,失败时返回-1。 |
read() | 功能:从指定的文件描述符中读取数据。 原型:ssize_t read(int fd, void *buf, size_t count); 返回值:成功时返回读取的字节数,失败时返回-1。 |
write() | 功能:向指定的文件描述符写入数据。 原型:ssize_t write(int fd, const void *buf, size_t count); 返回值:成功时返回写入的字节数,失败时返回-1。 |
lseek() | 功能:在文件中重新定位文件描述符的读写位置。 原型:off_t lseek(int fd, off_t offset, int whence); 返回值:成功时返回新的文件偏移量,失败时返回-1。 |
ioctl() | 功能:执行设备特定的操作,如设置设备参数或检索状态信息。 原型:int ioctl(int fd, unsigned long request, ...); 返回值:成功时返回0或正结果,失败时返回-1。 |
fcntl() | 功能:对文件描述符进行各种控制操作,如获取或修改文件状态标志。 原型:int fcntl(int fd, int cmd, ... /* arg */ ); 返回值:根据命令不同,返回值不同,失败时返回-1。 |
select() / poll() / epoll() | 功能:监视多个文件描述符,等待它们中的任何一个变得可读、可写或有异常状态。 原型:这些函数的原型各不相同,但它们的基本目的是相似的。 返回值:成功时返回准备好的文件描述符的数量,失败时返回-1。 |
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
printf函数:
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps63.jpg&pos_id=jUcKa1sV)
vsprintf函数:vsprintf的作用就是格式化并返回要打印出来的字符串的长度。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps64.jpg&pos_id=8K84BdFe)
字符串的长度i在下一句传给write函数:
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps65.jpg&pos_id=ZeNKODOG)
![](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=file%3A%2F%2F%2FC%3A%5CUsers%5CZHB%5CAppData%5CLocal%5CTemp%5Cksohtml2628%5Cwps66.jpg&pos_id=lv3UKQOR)
write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,最后,write函数调用syscall(int INT_VECTOR_SYS_CALL)。syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过 ASCII 码在字模库中找到点阵信息将点阵信息存 储到 vram 中。显示芯片会按照一定的刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点(RGB 分量),最终打印出了我们需要的字符串。
8.4 getchar的实现分析
getchar函数:
int getchar(void){
static char buf[BUFSIZ];
static char *bb = buf;
static int n = 0;
if(n == 0)
{
n = read(0, buf, BUFSIZ);
bb = buf;
}
return(--n >= 0)?(unsigned char) *bb++ : EOF;
}
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,并分析了 printf 函数和 getchar 函数。
结论
hello所经历的过程总结:
编写源代码:程序员使用文本编辑器编写 "Hello" 程序的源代码(hello.c),通常包含一个主函数 main(),用于输出 "Hello, World!" 到控制台。
预处理:源代码经过预处理器(cpp)处理,展开宏定义,包含头文件,处理条件编译指令,生成预处理后的文件(hello.i)。
编译:编译器(gcc 或 clang 等)将预处理后的文件编译成汇编语言文件(hello.s),这一步将高级语言转化为机器可理解的低级指令。
汇编:汇编器(as)将汇编语言文件转换为可重定位的机器代码,生成目标文件(hello.o)。
链接:链接器(ld)将一个或多个目标文件与库文件链接,解决外部符号引用,生成可执行文件(hello)。
加载:操作系统的加载器(在某些系统中是动态链接器)将可执行文件加载到内存中,为程序分配地址空间。
进程创建:Shell 或命令行界面接收到用户输入的命令后,通过 fork() 系统调用创建一个新的进程。
执行:新创建的进程通过 execve() 系统调用加载程序的代码和数据到其地址空间,并开始执行 main() 函数。
运行时环境设置:操作系统为进程设置运行时环境,包括栈、堆和全局变量等。
I/O 操作:程序在执行过程中,可能会进行输入输出操作,如使用 printf() 函数输出字符串到控制台。
内存管理:程序运行时,操作系统负责内存管理,包括虚拟内存的映射、物理内存的分配和回收。
进程调度:操作系统通过调度器进行进程调度,根据调度算法决定哪个进程获得 CPU 时间片。
信号处理:程序在运行过程中可能会响应外部信号(如 SIGINT),进行相应的处理。
退出:程序执行完 main() 函数后,通过 return 语句或 exit() 函数退出,操作系统回收其资源。
父进程回收:如果是 fork() 创建的子进程,父进程将等待子进程结束,并通过 wait() 系统调用来回收子进程的资源。
系统调用返回:程序的所有系统调用在执行完毕后,都会返回到用户空间,继续执行用户程序。
程序消亡:程序结束后,操作系统彻底清理其占用的资源,包括文件描述符、内存空间等,程序的生命周期结束。
对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法:
源代码程序在计算机中的一步步严谨的实现精妙无比,帮助我更好了解了计算机系统,计算机系统的现象在我眼中逐渐生动起来。
附件
hello.c:源程序文件;
hello.i:修改了的源程序;
hello.s:汇编程序文本文件;
hello.o:可重定位目标程序(二进制文件);
hello:可执行目标程序;
Hello.asm:hello的反汇编文件
hello.elf:hello.o的elf输出
Hello1.elf:Hello的elf输出
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 编译基础-从hello.c到hello可执行文件的过程_hello.c 二进制-CSDN博客
[2] https://blog.csdn.net/weixin_51909904/article/details/124880980