计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能领域方向
学 号 202211xxxx
班 级 22WL0xx
学 生 陈xx
指 导 教 师 刘宏伟
计算机科学与技术学院
2024年5月
本文深入探讨了程序hello的完整生命周期,从最初的hello.c源文件,到最终成为可执行文件hello的完整过程。文章详细描述了hello在进程中的加载、运行和回收等关键阶段,全面分析了hello在生命周期中所经历的P2P以及O2O等关键环节。通过对hello的深入剖析,本文旨在揭示计算机系统底层的核心工作机制,包括程序如何从.c源文件生成,以及进程如何进行有效的运行和管理。
关键词:计算机系统;编译;汇编;链接;进程;存储管理;IO管理;
目 录
第1章 概述
1.1 Hello简介
1.1.1 P2P (From Program to Process)
P2P过程,即从编写源代码hello.c至最终作为一个进程在操作系统内部得以运行的完整流程,具备高度的严谨性和系统性。该过程起始于源代码hello.c,其将依次经历预处理、编译、汇编和链接等多个精细化步骤,最终转化为一款可执行程序。该程序随后将被载入系统内存之中,进而转变成一个由操作系统进行管理与执行的进程。
在此过程中,操作系统OS负责进程管理,包括调度进程运行、分配资源等。在壳Bash中,OS会为Hello这个进程进行fork和execve等操作,让其能够在CPU、RAM和IO设备上执行。操作系统的存储管理与内存管理单元MMU负责虚拟地址到物理地址的转换,TLB、Cache等组件提高程序运行效率。同时,IO管理与信号处理使操作系统能够与外部设备通信。
图 1 P2P过程图示
1.1.2 020 (From Zero to Zero)
020过程代表了程序hello.c的生命周期,从0开始,到0结束。程序开始运行时,shell使用fork函数创建一个新的进程,在进程中调用execve函数将hello程序加载到相应的上下文中,并将程序内容载入物理内存,之后调用main函数。当程序运行结束时,整个过程的子进程变成僵死进程,父进程调用waitpid函数回收子进程,之后删除程序运行时创建的相关内容,实现从0到0的过程。
1.2 环境与工具
处理器 12thGenIntel(R)Core(TM)i7-12700H2.30GHz
机带RAM 16.0GB(15.7GB可用)
1.2.2软件环境
VMwareWorkstation17Player;
Ubuntu-22.04.4
1.2.3开发工具
VScode;Codeblocks;gcc;objdump
1.3 中间结果
本文所有生成的中间结果文件及其作用如表1所示。
表 1 中间结果文件
文件名 | 作用/文件描述 |
hello.c | C源程序文件 |
hello.i | 源程序hello.c经过预处理后的文件 |
hello.s | hello.i经过编译后的汇编语言文件 |
hello.o | hello.s经过汇编后的可重定位目标文件 |
hello | hello.o经过链接后的可执行目标文件 |
hello.efl | hello.o的ELF格式文件 |
1.4 本章小结
本章简要介绍了hello.c程序的P2P过程和020过程,说明了论文所用到的计算机软硬件环境和开发工具,并列举了所有生成的中间结果文件。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理是指在源代码被编译器正式转换为机器代码之前,对其进行的一系列文本级别的处理步骤。这个过程通常由预处理器来完成。预处理的操作主要包括宏定义(Macro Definition)、文件包含(File Inclusion)、条件编译(Conditional Compilation)、行控制(Line Control)等
2.1.2 预处理的作用
(1)通过宏定义和文件包含,可以使代码更加模块化,提高代码可读性和维护性。
(2)实现条件编译,根据不同条件编译不同的代码段,支持多平台开发和功能开关,提高了代码的灵活性和可移植性。
(3)通过宏替换,可以减少重复代码,简化代码编写。
2.2在Ubuntu下预处理的命令
在Ubuntu中的终端命令行输入gcc -E hello.c -o hello.i命令,生成hello.i文件。
图 2 预处理命令
图 3 hello.i文件部分展示1
图 4 hello.i文件部分展示2
2.3 Hello的预处理结果解析
hello.c源程序经过预处理后得到hello.i文件,查看其文本内容,如图3图4所示,可以发现代码行数增加至3092行,hello.c程序中代码位于hello.i文件中最后部分,hello.i的其他部分代码主要是typedef、struct以及一些地址等组成,它们的作用是对系统头文件进行引用和宏定义展开。
2.4 本章小结
本章介绍了预处理的概念和作用,并在Ubuntu中通过预处理命令,对hello.c进行预处理,得到hello.i文件,同时对hello.i文件内容进行简要分析。
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译是指编译器将预处理产生的.i文件,通过词法分析、语法分析、语义分析、优化等阶段后,生成汇编语言程序文件的过程。
3.1.2 编译的作用
(1)通过实现高级语言向特定平台机器码的转换,确保了程序能够在多样化的硬件架构上实现顺畅运行。
(2)编译生成的机器码相较于解释执行的代码,通常具备更高的运行效率。
(3)编译过程包含语法和语义分析环节,能够实现一些错误检测的操作。
(4)编译后的程序以机器码形式呈现,增强了代码的安全性和保密性。
3.2 在Ubuntu下编译的命令
在Ubuntu中的终端命令行输入gcc -S hello.i -o hello.s命令,生成hello.s文件。
图 5 编译命令
3.3 Hello的编译结果解析
3.3.1 文件基本信息
如图6所示,hello.s文件的开始部分中:
.file表示源文件hello.c程序;
.text对代码节进行了声明,表示代码段由此开始;
.section和.rodata表示以下内容为只读数据段;
.align 8说明以下内容的数据对齐为8字节;
.LC0和.LC1表示的是字符串常量的标签,
.string及其后面的引用内容表示字符串常量的内容;
.global对全局变量进行了声明,而.type声明的则是变量类型,二者与@function在hello.s中是对全局符号main进行声明并表示main是函数。
图 6 hello.s文件基本信息
3.3.2 数据
(1)整型数据
①参数argc:
argc表示输入的参数个数,并将参数个数存储在%edi中,接着通过movl指令,再将edi中的参数个数传入到-20(%rbp)中,进一步检查参数个数是否为5个。
图 7 main函数部分信息
②局部变量:
hello.c程序中使用了for循环,定义了局部变量int i,i被存储在-4(%rbp)中,并初始化为0。
图 8 局部变量的初始化
(2)字符串数据
hello.c程序中的字符串类型,是程序执行时输出的字符串信息。字符串信息如图6所示,.LC0和.LC1表示的是字符串常量的标签,.string及其后面的引用内容表示字符串常量的内容。
(3)指针数组
argv[]是我们将要在命令行中输入的字符串数据,它的首地址被放在%rsi中。
图 9 argc[]指针数组初始化
3.3.3 算术计算
在for循环中使用了i++,在hello.s中显示为图10。
图 10 i++编译结果
3.3.4 关系操作与控制转移
hello.s中通过两个参数的大小关系,结合跳转命令。控制转移是指条件、循环等控制程序执行顺序方式的操作。
在hello.c程序中,首先判断的是-20%rbp)中的argc输出字符串的参数个数和5的大小,通过je可知,若argc刚好为5则跳转到.L2,继续执行程序。
图 11 argc参数的关系操作
其次,在for循环中还需要判断i的大小关系,i是否满足循环条件i<10,hello.s中通过cmpl比较存放在-4(%rbp)中的i和数字9的大小,通过跳转指令jle,只要i小于等于9则跳转到.L4继续执行程序。
图 12 i的关系操作
3.3.5 指针数组的操作
指针数据char *argv[]的首地址放在%rsi中,之后被转移到-32(%rbp),因此-32(%rbp)中存放的是argv[0],通过addq指令,在-32(%rbp)上加入偏移量8*i,可以得到对应的argv[i],如图13所示。
图 13 argv[]数组的操作
3.3.6 函数的调用与返回
函数调用时,利用指令call来实现。首先将当前指令执行的地址压入栈中,随后执行目标函数。待调用的函数执行完毕后,将栈顶保存的当前地址弹出,从而恢复执行流程,继续执行后续的程序。
若函数调用时需要传递参数,前六个参数将通过寄存器进行传递,而超出的其他参数则通过栈来进行传递。函数执行完成后,其返回值将保存在寄存器%rax中。
在hello.c中,调用了printf、atoi、sleep函数。指令将数值0存入寄存器%eax,表示程序执行成功并准备返回。最后通过ret指令将控制权返回至main函数的调用者,从而完成整个程序的执行过程。
图 14 函数调用
3.4 本章小结
本章简要介绍了编译的概念及其作用,接着对hello.i进行编译得到hello.s文件,通过查看hello.s文件信息对其包含的汇编代码进行逐步分析,包括数据类型、赋值、关系操作、控制转移、函数调用返回等操作。
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是指将编译得到的汇编程序.s,通过汇编器,逐条转换为对应的机器语言指令,然后将这些机器语言指令打包成可重定位目标程序格式,结果保存在二进制文件.o中。
4.1.2 汇编的作用
将汇编指令程序翻译成机器语言指令,便于计算机进行读取分析和执行。
4.2 在Ubuntu下汇编的命令
在Ubuntu中的命令行输入gcc -c hello.s -o hello.o,生成可重定位目标文件hello.o。
图 15 汇编命令
4.3 可重定位目标elf格式
4.3.1 典型的ELF可重定位目标文件格式
图 16 经典的ELF可重定位目标文件格式
4.3.2 hello.elf的获取
使用readelf获得hello.o的ELF格式文件。
图 17 获取hello.elf文件
4.3.3 ELF格式的分析
(1)ELF Header(ELF头):
ELF Header包含一个16字节的序列7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00,用于描述hello.elf文件的基本信息,包括数据为二进制补码,小端序列,REL可重定位文件等。此外,ELF Header还包含了帮助链接器分析语法和解释目标文件的信息。
图 18 ELF Header
(2)Section Headers(节头部表):
Section Headers用于描述不同节的位置和大小,对于目标文件中的每一个节,都应该有一个条目,描述了节的名称Name、类型Type、地址Address和偏移量等,如下图所示。
图 19 Section Headers
(3)Relocation section可重定位节
当汇编器生成一个目标模块时,并不知道数据和代码最终将放在内存中的什么位置,也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。因此,汇编器对于这些目标模块引用,会生成一个重定位条目,用于告诉链接器在将目标文件合并成可执行目标文件时修改这个引用。而生成的重定位条目,与汇编生成的机器语言指令,一同放在可重定位目标文件.o中。更具体来说,引用的重定位条目放在.rela.text中,数据引用的重定位条目放在.rel_data中。
图 20 Relocation section
(4)Symbol table符号表
Symbol table用于存放程序中所定义和引用的函数以及全局变量的相应信息。
图 21 Symbol table
4.4 Hello.o的结果解析
4.4.1 获取hello.o的反汇编
在命令行中输入objdump -d -r hello.o,得到hello.o的反汇编。
图 22 hello.o的反汇编
图 23 hello.s文件部分代码指令
4.4.2 比较分析
将图22与图23的hello.s文件进行比较,发现二者在指令代码上非常相似,例如算术操作等。但二者仍有明显的不同,具体的差异包括以下几点:
(1)机器语言的构成
反汇编文件中包含着源程序中每一条语句的机器指令,同时这些指令不仅 包含了操作码和地址码,而且每一条指令前还包含了该指令对应的地址。
(2)分支转移
hello.s文件中,控制转移通过cmpl和je实现,而跳转的目标是段.L2、.L3等。 但是在反汇编文件中,控制转移的跳转实现依靠于函数首地址及其对应的 地址偏移量。
(3)函数调用
在hello.s和反汇编文件中,函数调用都使用指令call,但是hello.s引用的是 对应函数的名称,而反汇编文件引用的是main函数首地址及地址偏移量, 通过main函数首地址与地址偏移量的算术操作,得到引用函数的具体地 址,实现对函数的调用。
4.5 本章小结
本章简要介绍了汇编的概念和作用,通过命令对hello.s汇编得到hello.o文件和hello.elf文件,并详细分析了hello.elf文件的ELF格式。之后通过gcc命令得到hello.o的反汇编文件,并将其与hello.s进行比较分析二者的不同之处。
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。
5.1.2 链接的作用
链接器将一个程序的不同源代码文件编译生成的目标文件合并成一个可执行文件。同时链接还能将程序中使用的库文件(如动态库或静态库)融入到可执行文件中,或者建立可执行文件与共享库的连接关系。此外,链接还可以执行一些优化操作,比如消除不必要的代码(如未被任何模块引用的函数或数据),或者对代码进行布局优化,以提高程序的运行效率。
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
图 24 链接命令
5.3 可执行目标文件hello的格式
5.3.1 典型的可执行目标文件格式
图 25 典型的可执行目标文件格式
5.3.2 分析
这里使用readelf -a hello指令读取可执行目标文件hello的信息。与可重定位目标文件hello.elf相比,可执行目标文件hello的基本格式与hello.elf相似,但是少了.real.txt和.real.data这两个节,原因是,在两个节的作用是重定位未知目标模块引用的位置,而重定位在可执行目标文件中已经完成了,未知目标模块引用已经被链接到正确的位置,因为可执行目标文件中不包含这两个节。
图 26 ELF Header
图 27 Section Headers
图 28 Program Headers和Segment Sections
图 29 其他信息
5.4 hello的虚拟地址空间
使用edb加载可执行目标文件hello,如下图所示。其初始化的起始位置,对应5.3中.init节,即起始的地址为0x0000000000401000。此外,hello文件中,还链接了动态共享库ld-linux-x86.64.so,图30显示了其对应的虚拟地址。
图 30 edb加载hello
5.5 链接的重定位过程分析
5.5.1 hello与hello.o反汇编比较
使用指令objdump -d -r hello查看hello的信息,并将其与hello.o反汇编比较。
图 31 hello文件信息1
图 32 hello文件信息2
图 33 hello文件信息3
通过比较,可以发现二者有以下几点不同:
(1)hello.o中访问的是相对地址,而在hello中变成了虚拟内存地址。
(2)hello中,链接加入了hello.c中引用到的库函数,包括exit、sleep、printf等。
(3)hello中多出了.init和.plt两个节,以及节中定义的一些函数。
(4)hello.o中包含重定位条目,而hello中没有重定位条目。
5.5.2 hello的重定位分析
重定位过程涉及对输入模块的整合,并依据预设规则为每个符号分配唯一的运行时地址。在此过程中,原本分散的代码节与数据节得以整合为单一的节结构。此外,该过程还负责将符号从其在.o文件中的相对位置迁移至可执行文件中的最终绝对内存位置,以确保符号在内存中的精确布局。为保持程序逻辑的正确性,链接器会进一步更新所有针对这些符号的引用,以反映其新的内存位置。
具体来说,重定位过程包含以下步骤:
首先,链接器会对节和符号定义进行重定位。在这一步骤中,所有相同类型的节将被归并至同一类型的全新节之中。例如,所有的.data节将融合成一个单一的节,构成输出可执行目标文件hello中的.data节;同理,所有的.text节也将合并为一个整体,以此类推。
其次,链接器将负责将符号从其原始的.o文件相对位置重新映射至可执行文件中的确切绝对内存地址。在此过程中,链接器将为新的聚合节、输入模块定义的每个节以及每个符号分配相应的运行时内存地址。当此步骤完成后,可执行文件hello中的每一条指令以及全局变量(如.rodata、sleepsecs等)都将获得唯一且精确的运行时内存地址。
最后,链接器将依据hello.o中的重定位条目,对代码节和数据节中的符号引用进行修正,确保它们指向正确的运行时地址,从而保障程序在执行过程中的稳定性和准确性。
5.6 hello的执行流程
使用edb执行hello,利用edb中的symbol以及debug查看程序的所有过程。
图 34 hello的执行流程
5.7 Hello的动态链接分析
在加载可执行文件的过程中,Hello程序会自动加载动态链接库ld-linux-x86-64.so.2。Hello程序中可能存在的某些部分会调用该动态链接库中的符号(如函数调用),此机制的核心实现依赖于程序链接表(PLT)和全局偏移表(GOT)。其中,PLT表和GOT表的每一个条目都精确对应着动态链接库中的符号引用。通过readelf工具,我们可以在其节表中查看关于PLT和GOT的详细信息。
图 35 .got和.got.plt
在程序启动时,首先执行_dl_start和_dl_init两个关键步骤。其中,_dl_init负责修改程序链接表(PLT)和全局偏移表(GOT),这一环节实质上等同于对动态链接库中的符号进行“注册”操作。通过这一流程,hello程序在后续的正常运行过程中能够成功引用这些符号,进而实现诸如间接跳转等关键功能。
图 36 Init前
图 37 Init后
5.8 本章小结
本章详细描述了从可重定位目标文件链接生成可执行文件的具体流程,深入剖析了可执行文件的ELF格式特点。同时,对比了可执行文件与可重定位目标文件在结构、功能等方面的显著差异。通过深入分析hello.o中的函数,揭示了重定位过程的实现机制。此外,通过edb工具,逐步执行了hello程序,全面阐述了整个程序的执行过程,并对动态链接过程进行了深入剖析。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程,即执行中的程序实例,是操作系统进行资源分配和调度的基本单位。每当用户通过Shell输入可执行目标文件的名称并启动程序时,Shell将负责创建一个全新的进程。
6.1.2 进程的作用
进程的主要作用在于为用户营造一种假象,即程序仿佛是系统中当前唯一运行的程序,独占处理器和内存资源。在此过程中,CPU似乎持续不断地执行程序中的指令,而程序中的代码和数据则似乎成为系统内存中的唯一对象。这种假象有助于简化用户的操作体验,使其无需关注底层系统的复杂性。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用
Shell-bash,不仅是提供用户与操作系统之间的交互接口,更是一个强大的脚本语言解释器,能够执行用户输入的命令、脚本,以及自动化管理任务。
6.2.2 Shell-bash的处理流程
用户输入命令或运行脚本时,bash首先进行词法分析,拆分输入字符串为单词或标记。接着进行语法分析,检查是否符合bash语法规则。符合规则则查找并执行相应命令,涉及环境变量解析、路径搜索、命令执行等。bash还提供内置命令和函数,实现文件操作、字符串处理、控制结构等功能,增强脚本处理能力。bash注重错误处理和异常捕获,生成错误消息,允许用户处理。
6.3 Hello的fork进程创建过程
Hello的fork进程创建涉及操作系统复制父进程内存映像,为子进程创建新进程空间。复制高效,子进程与父进程运行环境相似但独立,拥有不同PID。创建后,操作系统调度两进程执行。fork后,通常通过if语句区分父子进程执行不同代码。
6.4 Hello的execve过程
Hello的execve过程是Linux系统下执行程序的关键步骤。Shell解析并执行命令,通过fork()创建子进程,然后子进程调用execve()加载并执行Hello程序。execve()会替换子进程的映像,加载Hello程序到内存,并设置相关数据结构。完成后,控制权转移给Hello程序的main()函数,程序按逻辑执行并结束。操作系统内核负责进程管理和系统调用,shell是用户与操作系统的接口,而可执行文件则包含加载和执行所需的信息。
6.5 Hello的进程执行
当用户在终端中输入并执行Hello命令时,操作系统会为该命令创建一个新的进程。这个进程在创建之初,会被赋予一个初始的上下文信息,包括程序计数器、寄存器状态、内存管理信息、打开的文件描述符等。这些信息共同构成了进程的上下文环境,为进程的执行提供了必要的条件。
在进程创建完成后,操作系统会将其放入就绪队列中等待调度。调度器会根据一定的算法(如先来先服务、优先级调度等)来选择下一个要执行的进程。当Hello进程被选中时,它会被赋予CPU的使用权,开始执行其代码。
在执行过程中,Hello进程会经历用户态和核心态的转换。用户态是进程执行用户级代码时的状态,此时进程只能访问受限的内存区域和执行受限的操作。而核心态则是进程执行内核级代码时的状态,此时进程可以访问所有的系统资源并执行所有的系统调用。
当Hello进程需要执行系统调用(如输出Hello World到终端)时,它会触发一个从用户态到核心态的转换。这个过程包括保存用户态的上下文信息、设置核心态的上下文信息、执行内核代码等步骤。在核心态下,操作系统会完成系统调用的功能,并将结果返回给进程。然后,进程会再次从核心态转换回用户态,继续执行其用户级代码。
此外,进程调度的时间片管理也是Hello进程执行过程中的重要环节。操作系统会为每个进程分配一个时间片,即CPU的使用时间。当Hello进程的时间片用完时,调度器会将其从CPU上取下,放入就绪队列的末尾,并选择另一个进程来执行。这样,操作系统可以确保每个进程都能得到公平的执行机会,避免某个进程长时间占用CPU资源。
6.6 hello的异常与信号处理
6.6.1 异常
hello执行过程中会产生以下几类异常。
(1)中断
中断的异步特性体现在其发生并非由任何特定的指令触发,而是源于处理器外部I/O设备发出的信号。这种来自外部的信号导致中断的发生,使得中断呈现出非同步的特性。对于硬件中断而言,其异常处理程序通常被称为中断处理程序,负责处理由中断引发的异常情况。
(2)陷阱
陷阱是经过精心设计的,旨在执行特定指令后产生预期结果的一种机制。类似于中断处理程序的作用方式,陷阱处理程序能够将控制权安全地返回至紧随其后的指令处。陷阱的核心价值在于为用户程序与内核之间搭建一个类似于过程调用的接口桥梁,这一桥梁通常被称作系统调用,从而实现了用户空间与内核空间之间的高效通信与交互。
(3)故障
对于所发生的情况,尽管并非有意为之,但已存在可能解决的方式。在处理程序中,可以选择重新执行导致故障的指令(当前已修复),或者选择终止程序的执行。
(4)终止
6.6.2 执行情况
(1)正常执行
图 38 正常执行
经输入命令ps确认,程序后台已无hello进程正在执行,表明该进程已正常结束并被系统成功回收。
(2)不停乱按
图 39 不停乱按
结果显示,不停乱按输入的字符,并不影响程序的运行,输入过程中没有产生任何影响进程的信号。
(3)Ctrl+Z
图 40 Ctrl+Z和ps查看进程
通过ps查看进程可知,存在hello进程,hello进程被挂起。
再通过jobs命令终止进程,输入pstree查看进程之间的关系。
图 41 pstree1
图 42 pstree2
重新运行hello程序,先使用Ctrl+Z挂起,再使用fg执行剩下的部分。
图 43 fg命令
再次重新运行hello程序,先使用Ctrl+Z挂起,再使用kill杀死该进程。
图 44 kill
(4)Ctrl+C
图 45 Ctrl+C
6.7本章小结
本章将对进程管理进行简要阐述,涵盖进程的基本概念及其作用,同时深入剖析Shell的核心原理。在Shell的运行机制中,涉及到了fork和execve的调用方式。进程在执行过程中,会遭遇多种不同情境下的反应,如回车键的输入、Ctrl-Z的暂停操作、Ctrl-C的中断指令等,以及针对Ctrl-z、运行ps、jobs、pstree、fg、kill等命令的相应处理机制。此外,本章还将介绍一些常见的异常情形及其对应的信号处理方法。鉴于执行环境的多变性和复杂性,hello程序已无法保持其原有的单纯性。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是程序在编写时所使用的地址,它通常与源代码中的变量和函数关联。在hello程序中,每个变量和函数都有一个逻辑地址,这些地址在程序编译时被确定,并用于在源代码中引用这些变量和函数。逻辑地址是程序员视角下的地址,它并不直接对应物理内存中的实际位置。
线性地址是逻辑地址经过一定的转换后得到的地址,它用于在虚拟内存空间中定位数据。在hello程序执行时,操作系统会将逻辑地址转换为线性地址。线性地址空间是连续的,并且通常比实际的物理内存要大得多。这使得程序可以访问比实际可用物理内存更大的内存空间,从而提高了内存利用率和灵活性。
在大多数情况下,线性地址和虚拟地址可以视为同一概念。虚拟地址空间是操作系统为每个进程提供的私有内存空间,它确保了不同进程之间的内存隔离。在hello程序运行时,操作系统会为其分配一个虚拟地址空间,并将逻辑地址转换为虚拟地址。这样,每个进程都认为自己拥有整个内存空间,而不会相互干扰。
物理地址是内存芯片中的实际地址,用于直接访问物理内存中的数据。在hello程序执行过程中,当需要访问某个数据时,操作系统会将虚拟地址转换为物理地址,然后通过物理地址从内存中读取或写入数据。物理地址的转换通常由内存管理单元(MMU)硬件完成,确保了程序对内存的透明访问。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在段式管理下,每个程序被划分为若干个逻辑段,这些段按照内容或过程(函数)关系进行组织,每个段都有自己的名字和属性。
当CPU需要访问某个内存地址时,它首先接收到的是一个逻辑地址。这个逻辑地址由段选择符和偏移量两部分组成。段选择符用于指示所要访问的段在段描述符表(GDT或LDT)中的位置,而偏移量则表示在所选段内的相对地址。
接下来,CPU会根据段选择符在GDT或LDT中找到对应的段描述符。段描述符中包含了段的基地址、长度以及访问权限等信息。通过将这些信息与逻辑地址中的偏移量相结合,CPU就可以计算出目标内存位置的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是现代操作系统中广泛采用的一种内存管理方式,它通过将内存划分为固定大小的页面(通常为4KB或更大)来简化地址变换的过程,并提供了高效的内存保护和共享机制。
在Hello的上下文中,线性地址是程序在逻辑上看到的连续地址空间,而物理地址则是这些地址在物理内存中的实际位置。页式管理的核心任务就是将线性地址转换为对应的物理地址,以便CPU能够正确地访问内存中的数据。
为了实现这一转换,操作系统维护了一个页表,它记录了每个线性地址页面所对应的物理地址页面。当CPU尝试访问一个线性地址时,它会查找页表以获取对应的物理地址。这个过程通常涉及硬件支持的页表查找机制,如TLB(Translation Lookaside Buffer)缓存,以提高查找效率。
除了地址转换,页式管理还有内存保护和共享功能。通过页表的权限位,操作系统限制对特定页面的访问,避免程序访问未授权内存。同时,通过共享物理页面和设置共享标志,多个程序可以共享数据,节省内存并提高性能。
7.4 TLB与四级页表支持下的VA到PA的变换
当CPU接收到一个虚拟地址访问请求时,它首先会尝试在TLB中查找该虚拟地址对应的物理地址。
TLB是一个高速缓存,用于存储最近访问过的页表条目,以加速虚拟地址到物理地址的转换过程。如果TLB中找到了匹配的条目,CPU可以直接从TLB中获取物理地址,无需再去访问页表,从而大大减少了转换延迟。
如果TLB中没有找到匹配的条目,CPU就需要去访问页表进行转换。在四级页表的支持下,虚拟地址被划分为多个部分,每一部分都对应着不同级别的页表。CPU会根据虚拟地址的各个部分,依次在各级页表中查找对应的物理页框号。
首先,CPU会在最高级别的页表(通常是PGD,即Page Global Directory)中查找一个条目,该条目指向下一级页表的物理地址。然后,CPU会根据找到的物理地址去访问下一级页表(例如PUD,即Page Upper Directory),继续查找对应的条目。这个过程会一直持续到找到最终的物理页框号为止。一旦找到了物理页框号,CPU就可以将其与虚拟地址中的偏移量相结合,形成完整的物理地址。然后,CPU就可以通过这个物理地址去访问内存中的数据或指令了。
值得注意的是,在访问页表的过程中,如果发生了页错误,例如访问了一个无效的虚拟地址,CPU会触发一个异常,由操作系统来处理这个错误。操作系统可能会选择加载一个新的页面到内存中,并更新页表以反映这个变化。然后,CPU会重新尝试进行虚拟地址到物理地址的转换。
图 46 TLB缓存
图 47 k级页表的地址翻译
7.5 三级Cache支持下的物理内存访问
在三级Cache支持下的物理内存访问机制的核心在于有效地预测和管理CPU对内存数据的需求,确保最可能被访问的数据始终保持在最快速的缓存级别中。
首先,三级Cache的设计遵循了程序局部性原理,包括时间局部性和空间局部性。这意味着最近被CPU访问的数据,以及这些数据附近的数据,在未来的一段时间内仍有可能被再次访问。因此,通过将这部分数据保存在缓存中,可以极大地减少CPU对主存的访问次数,从而提高数据访问速度。
在三级Cache的架构中,每一级缓存都有其特定的容量和访问速度。一级缓存(L1 Cache)通常是最小的,但速度最快,它直接集成在CPU内部,用于存储CPU最常访问的数据和指令。当CPU需要读取或写入数据时,它会首先检查L1 Cache中是否已存在所需的数据。如果存在,则直接进行访问,避免了访问主存的延迟。
如果L1 Cache中未找到所需数据,CPU会转向二级缓存(L2 Cache)。L2 Cache的容量通常比L1 Cache大,但速度稍慢。它同样用于存储CPU可能访问的数据,以进一步减少对主存的访问。如果L2 Cache中也未找到所需数据,CPU会转向三级缓存(L3 Cache)。L3 Cache的容量最大,但速度相对较慢。它通常被设计为多个CPU核心共享,用于存储多个核心都可能访问的数据。
7.6 hello进程fork时的内存映射
当hello进程执行fork操作时,它会创建一个子进程。
子进程是父进程的副本,它们共享相同的代码段,但拥有各自独立的数据段、堆和栈。这种内存映射的复制方式确保了父子进程在逻辑上是相互独立的,但同时又保留了父进程的执行环境。
在fork操作发生时,操作系统会执行一系列复杂的步骤来确保内存映射的正确性。首先,它会复制父进程的代码段,因为这部分内存是只读的,并且多个进程可以同时访问相同的代码段而不会相互干扰。接下来,操作系统会为子进程分配新的数据段、堆和栈空间,这些空间在物理内存上是与父进程相互独立的。
在内存映射的复制过程中,还需要注意一些细节。例如,父进程打开的文件描述符在子进程中也会被复制,这意味着子进程可以继承父进程对文件的访问权限。此外,父进程的环境变量和命令行参数也会被复制到子进程中,以确保子进程在运行时能够正确获取这些信息。
虽然fork操作在逻辑上创建了一个完整的进程副本,但在实际实现中,操作系统通常会采用写时复制Copy-On-Write技术来优化性能。这意味着在fork操作刚完成时,父子进程实际上共享相同的物理内存页。只有当其中一个进程尝试修改这些共享页时,操作系统才会触发实际的内存复制操作,确保每个进程拥有自己独立的内存空间。
7.7 hello进程execve时的内存映射
当hello通过execve函数调用启动时,内存映射为新的程序创建适当的地址空间,并映射所需的文件和数据段。
在调用execve时,内核首先会销毁当前进程的旧地址空间,包括代码段、数据段、堆和栈等。然后,它会为新程序创建一个全新的地址空间,并设置相应的权限和属性。
接下来,内核会读取hello程序的二进制文件,并根据文件中的信息来构建新的内存映射,包括将程序的代码段映射到只读区域,数据段和BSS(未初始化数据段)映射到可写区域,以及为堆和栈分配空间。
在映射过程中,内核会确保所有必要的页面都被正确地分配和初始化。对于代码段和数据段,内核通常会使用文件的物理页面来直接映射到虚拟地址空间,这样可以节省内存并提高性能。而对于堆和栈,内核则会分配新的物理页面来满足需求。
一旦内存映射完成,新的程序就可以开始执行了。此时,所有的代码、数据和栈空间都已经按照程序的需求映射到了虚拟地址空间中,程序可以正常访问这些内存区域。
7.8 缺页故障与缺页中断处理
在执行CPU的内存访问指令时,若页表中的PTE(页表条目)表明目标地址所对应的页面并未驻留在物理内存中,则会触发缺页异常。这一异常将促使CPU由用户态切换至内核态,并激活操作系统内置的缺页中断处理机制。随后,缺页中断处理程序会根据特定的置换策略,将位于磁盘上的相应页面加载至物理内存中,并同步更新页表信息。待缺页异常处理流程完毕后,CPU将重新执行先前因缺页而中断的指令。
图 48 缺页中断处理流程
7.9动态存储分配管理
在hello程序中所使用的printf函数,依赖于动态内存分配器提供的动态内存分配机制。动态内存分配器的主要职责在于管理进程虚拟地址空间中的堆区域,它将堆视作一系列不同大小的内存块集合来进行有效维护。这些内存块表现为一段段连续的虚拟内存碎片,它们的状态要么是已分配,要么是空闲。空闲的内存块将保持其空闲状态,直到被应用程序所请求并进行分配。而已经分配的内存块则会保持其已分配状态,直至被显式地释放回系统。
7.10本章小结
本章简要介绍了hello程序内存访问背后的复杂机制,特别是基于页式管理的虚拟内存机制。对于特定地址处的数据访问,我们详细研究了基于段描述符的逻辑地址到线性地址的转换过程,以及基于分页机制的线性地址到物理地址的转换方法。此外,还对TLB与Cache的作用机制进行了深入剖析,并对缺页故障等机制以及内存映射进行了全面阐述。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux系统将文件与所有的I/O设备均统一模型化为文件形式,甚至将内核亦映射为文件处理。这种将设备高效且优雅地映射为文件的方法,赋予了Linux内核一个应用接口即Unix I/O。Linux正是基于Unix I/O接口,实现了对各类设备的高效管理。
8.2 简述Unix IO接口及其函数
Unix IO接口允许用户通过一组统一的接口来处理不同类型的文件和设备。
在Unix IO接口中,最基本和常用的函数包括open()、read()、write()、lseek()和close()等。
open()函数用于打开或创建一个文件,并返回一个文件描述符,该文件描述符在后续的文件操作中作为唯一标识。
read()函数用于从文件中读取数据到内存缓冲区中。
write()函数则用于将数据从内存缓冲区写入文件中。
lseek()函数用于设置文件的读写位置,通过改变文件指针的位置来实现文件的定位。
close()函数用于关闭打开的文件,释放相关资源。
除了这些基本函数外,Unix IO接口还提供了一些高级函数,例如使用select()或poll()函数来监视多个文件描述符的状态变化,从而实现并发IO操作。
8.3 printf的实现分析
printf函数的函数体如下图所示。
图 49 printf函数
vsprintf函数负责将所有参数内容按指定格式整理后存储于buf中,并返回经格式化处理的数组长度。随后,write函数将buf中的i个元素输出至终端界面。整个流程始于vsprintf函数生成显示信息,经write系统函数处理,最终触发陷阱-系统调用(如int 0x80或syscall)。在字符显示驱动子程序中,信息从ASCII编码转换至字模库,并最终呈现于vram(存储每个像素点的RGB颜色信息)中。显示芯片则依照预设的刷新频率逐行读取vram内容,并通过信号线将每个像素点的RGB分量信息传输至液晶显示器,实现图像的最终呈现。
图 50 vsprintf函数
8.4 getchar的实现分析
当程序执行到getchar函数时,它会暂停执行并等待用户输入字符。用户输入的字符将被存储在键盘缓冲区中,直到用户按下回车键(回车键的字符同样会被存入缓冲区)。在用户按下回车键之后,getchar函数开始从stdio流中逐个读取字符。此函数的返回值即为用户输入的第一个字符的ASCII码值;若发生错误,则返回-1。同时,getchar函数会将用户输入的字符实时显示在屏幕上。若用户在按下回车键之前输入了多个字符,这些未被读取的字符将继续保留在键盘缓冲区中,以供后续的getchar函数调用时读取。这意味着,后续的getchar函数调用将不再需要等待用户输入,而是直接读取缓冲区中的字符,直至缓冲区为空后,才会再次等待用户按键。
对于异步异常处理,当用户进行键盘操作时,会触发键盘中断。此时,操作系统会将控制权转移至键盘中断处理子程序。该子程序负责执行中断处理任务,包括接收按键的扫描码,将其转换为ASCII码,并保存到系统的键盘缓冲区中,同时更新用户输入终端的显示内容。当中断处理子程序执行完毕后,控制权将返回到原程序的下一条指令继续执行。
8.5本章小结
本章介绍了Linux的IO设备管理方法,以及Unix IO接口及其函数的功能,并且分析了printf和getchar两个函数的实现。
结论
hello的一生经历了以下的过程:
1. hello.c文件经过预处理阶段,转化为hello.i文本文件;
2. hello.i文件经过编译过程,生成hello.s汇编文件;
3. hello.s汇编文件通过汇编阶段,转化为二进制可重定位目标文件hello.o;
4. hello.o文件经过链接过程,生成可执行文件hello。
5. 通过在shell中输入命令./hello运行程序hello,bash进程通过调用fork函数,成功生成子进程;
6. execve函数在当前进程的上下文中加载并运行新程序hello;
7. hello通过Unix IO接口中的write和read函数,调用printf和getchar函数,实现信息的输出;
8. hello程序运行结束后,被Shell父进程回收,同时内核删除其所有的数据,hello的一生正式完结。
附件
表 2 附件名称及其作用
文件名 | 作用/文件描述 |
hello.c | C源程序文件 |
hello.i | 源程序hello.c经过预处理后的文件 |
hello.s | hello.i经过编译后的汇编语言文件 |
hello.o | hello.s经过汇编后的可重定位目标文件 |
hello | hello.o经过链接后的可执行目标文件 |
hello.efl | hello.o的ELF格式文件 |
图 51 附件
参考文献
[1] 《深入理解计算机系统》Randal E. Bryant David R.O`Hallaron
[2] http://blog.csdn.net/xiaoguaihai/article/details/8705992