计算机科学与技术学院
2024年5月
摘 要
伴随着屏幕上显示出的一段简短而深远的字符串:"Hello, world!",C语言在贝尔实验室诞生。这一简单的问候不仅宣告了C语言的到来,也开启了一个编程的新时代。C语言的设计者Dennis Ritchie与计算机科学家Brian W. Kernighan合作编写了经典著作《The C Programming Language》,其第一个示例程序便是在屏幕上输出“Hello, world!”。从此,打印“Hello, world!”成为了程序员学习任何编程语言时的传统开场。
然而,这一简单的程序背后,隐藏着计算机系统的深层奥秘。源程序文件hello.c是如何通过编译系统转换为可执行的目标文件hello?这些可执行文件内部的结构是什么样的?程序又是如何在计算机中运行起来的?运行时,程序在内存中又是如何组织和管理的?本文通过对hello程序生命周期的详细剖析,结合卡内基梅隆大学(CMU)参考教材《深入理解计算机系统》(CSAPP),对计算机系统的编译、运行、存储等机制进行了全面而深入的分析和介绍。
关键词:计算机系统;进程管理;编译系统;存储管理;
目 录
第1章 概述................................................................................... - 4 -
1.1 Hello简介............................................................................ - 4 -
1.2 环境与工具........................................................................... - 4 -
1.3 中间结果............................................................................... - 4 -
1.4 本章小结............................................................................... - 4 -
第2章 预处理............................................................................... - 5 -
2.1 预处理的概念与作用........................................................... - 5 -
2.2在Ubuntu下预处理的命令................................................ - 5 -
2.3 Hello的预处理结果解析.................................................... - 5 -
2.4 本章小结............................................................................... - 5 -
第3章 编译................................................................................... - 6 -
3.1 编译的概念与作用............................................................... - 6 -
3.2 在Ubuntu下编译的命令.................................................... - 6 -
3.3 Hello的编译结果解析........................................................ - 6 -
3.4 本章小结............................................................................... - 6 -
第4章 汇编................................................................................... - 7 -
4.1 汇编的概念与作用............................................................... - 7 -
4.2 在Ubuntu下汇编的命令.................................................... - 7 -
4.3 可重定位目标elf格式........................................................ - 7 -
4.4 Hello.o的结果解析............................................................. - 7 -
4.5 本章小结............................................................................... - 7 -
第5章 链接................................................................................... - 8 -
5.1 链接的概念与作用............................................................... - 8 -
5.2 在Ubuntu下链接的命令.................................................... - 8 -
5.3 可执行目标文件hello的格式........................................... - 8 -
5.4 hello的虚拟地址空间......................................................... - 8 -
5.5 链接的重定位过程分析....................................................... - 8 -
5.6 hello的执行流程................................................................. - 8 -
5.7 Hello的动态链接分析........................................................ - 8 -
5.8 本章小结............................................................................... - 9 -
第6章 hello进程管理.......................................................... - 10 -
6.1 进程的概念与作用............................................................. - 10 -
6.2 简述壳Shell-bash的作用与处理流程........................... - 10 -
6.3 Hello的fork进程创建过程............................................ - 10 -
6.4 Hello的execve过程........................................................ - 10 -
6.5 Hello的进程执行.............................................................. - 10 -
6.6 hello的异常与信号处理................................................... - 10 -
6.7本章小结.............................................................................. - 10 -
第7章 hello的存储管理...................................................... - 11 -
7.1 hello的存储器地址空间................................................... - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理................... - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理............. - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换.................... - 11 -
7.5 三级Cache支持下的物理内存访问................................ - 11 -
7.6 hello进程fork时的内存映射......................................... - 11 -
7.7 hello进程execve时的内存映射..................................... - 11 -
7.8 缺页故障与缺页中断处理................................................. - 11 -
7.9动态存储分配管理.............................................................. - 11 -
7.10本章小结............................................................................ - 12 -
第8章 hello的IO管理....................................................... - 13 -
8.1 Linux的IO设备管理方法................................................. - 13 -
8.2 简述Unix IO接口及其函数.............................................. - 13 -
8.3 printf的实现分析.............................................................. - 13 -
8.4 getchar的实现分析.......................................................... - 13 -
8.5本章小结.............................................................................. - 13 -
参考文献....................................................................................... - 16 -
第1章 概述
1.1 Hello简介
P2P:即Program to Process,在计算机系统中,一个名为hello.c的源程序(Source program)是程序生命周期的起点。这个源程序由GCC编译器的驱动程序(如gcc)读取,它负责协调编译过程中的各个阶段。首先,预处理器(cpp)根据以字符 # 开始的预处理指令对源文件进行扩充和修改,生成hello.i,一个经过预处理的源文件。接着,编译器(ccl)将hello.i翻译成汇编语言程序hello.s。然后,汇编器(as)将hello.s转换成机器语言指令,生成一个可重定位的目标程序hello.o。最后,链接器(ld)将hello.o与系统库中的printf.o等其他目标文件链接合并,生成最终的可执行目标文件hello。操作系统的壳(shell)利用fork函数为hello创建一个新的进程(process)。这个进程是操作系统为执行hello程序而分配的独立执行环境。一旦进程被创建,操作系统通过execve函数加载可执行目标文件hello到这个新进程的地址空间中。至此,hello的P2P过程完成,hello从一个静态的程序代码(Program)转变为一个动态的执行实体(Process)。
O2O:当shell调用execve函数加载程序hello时,O2O过程便开始了。首先,shell会删除当前子进程虚拟地址空间用户部分已存在的数据结构,为hello程序的代码、数据、bss(未初始化数据)和栈区域创建新的虚拟内存区域。接着,共享区域如库代码被映射到进程的地址空间,程序计数器被设置指向程序的入口点,即代码区域的起始位置。CPU开始在流水线上执行指令,程序hello开始运行。在执行过程中,CPU会根据程序计数器的指示,从内存中取出指令并执行。程序运行结束后,会发送一个信号给shell,告知程序已经完成执行。这时,shell会接收到这个信号,并开始回收hello进程所占用的资源,包括关闭打开的文件描述符、释放内存等。内核也会删除与hello进程相关的所有数据结构,并释放分配给该进程的内存资源。随着资源的回收和数据结构的删除,hello进程彻底结束,从系统中消失,完成了从存在(0)到不存在(0)的转变,即O2O过程。这个过程标志着程序hello的生命周期结束,它曾经存在过,执行过它的功能,最终又回归到无。
1.2 环境与工具
硬件环境:X64 CPU;2.30GHz;16.0GB RAM;1TD
软件环境:Windows11 64位;VMware 17.0.2;Ubuntu 20.04 LTS 64位
开发工具:Visual Studio 2022 64位;gdb;edb;vi/vim;gcc
1.3 中间结果
文件名称 | 文件作用 |
hello.c | 源代码文件 |
hello.i | 预处理得到的代码文件 |
hello.s | 汇编代码文件 |
hello.o | 可重定位目标文件 |
hello | 可执行目标文件 |
hello.o.elf | hllo.o的ELF |
hello.o.s | hello.o反汇编后的代码 |
hello.elf | hello的ELF |
hello_e.s | hello的反汇编代码 |
图 1
1.4 本章小结
本章节详细阐述了hello程序从源代码到进程的转变(P2P),以及从启动到结束的生命周期(O2O)。同时,章节中还介绍了用于实现这一过程的实验环境和所采用的开发工具。列举了在编译过程中生成的中间文件及其各自的功能和重要性。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理是编译源代码前的一个阶段,主要任务是执行文本替换、文件包含和宏扩展等操作。预处理器(cpp)会处理源文件中的特定指令,如#include和#define,以生成一个更简洁的源代码版本供编译器进一步处理。这个过程不涉及代码的逻辑解析,而是为编译器准备一个更规范的输入。预处理完成后,通常生成的文件以".i"为扩展名,然后编译器会将这个文件编译成最终的可执行代码。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
预处理过程如下:
图 2
2.3 Hello的预处理结果解析
当前的hello.i共3061行,开始的代码为各种库文件,中间是内部函数的声明,结尾是源代码。如下图所示
![]() | ![]() |
![]() |
2.4 本章小结
本章节深入探讨了预处理的基本概念及其在编译过程中的重要作用。通过分析一个具体的示例——对"hello"程序进行预处理后生成的文件hello.i
,我们详细了解了预处理如何将原始源代码转化为一个完整的文本,使其能够适配操作系统环境并准备执行。预处理不仅仅是简单的文本扩展,它实质上是对源代码的全面补全,确保了程序代码可以正确地在操作系统中运行。
第3章 编译
3.1 编译的概念与作用
编译器(通常缩写为 ccl)的主要功能是将经过预处理的文本文件转换为汇编语言程序的文本文件。在这个过程中,编译器将源代码输入扫描器,对源代码的字符序列进行分析并生成语法树。随后,语义分析器对语法树进行评估,判断语句是否符合语法规则。编译器还会对程序进行多种等价变换,以生成更为高效的目标代码。最终,目标代码生成器将经过语法分析或优化后的中间代码转换成汇编语言代码,为不同高级语言的编译器提供了通用的输出语言。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
汇编过程如下:
3.3 Hello的编译结果解析
3.3.1 数据
- 立即数
在hello.c中出现的数字被视为立即数常量,以 $立即数 的形式直接存放在汇编代码中。如下图所示
C语言代码 | 汇编代码 |
![]() | ![]() |
- 格式串
由printf函数打印的格式串包括格式控制字符串和转义字符,它们是一种特殊的字符串常量,以UTF-8编码形式存放在.rodata节的段.LC0与.LC1中。在访问时,通过rip加上偏移量的间接寻址来获取这些字符串。
![]() | ![]() |
![]() |
![]() | ![]() |
![]() |
- 局部变量
在hello.c中,局部变量在程序运行时存储在函数调用栈中。访问这些局部变量时,通常通过rbp加上偏移量的间接寻址来实现。
![]() | ![]() |
![]() | ![]() |
3.3.2 赋值
hello.c中的赋值操作主要通过MOV类指令实现,代码中的示例如下:
![]() | ![]() |
3.3.3 算术操作
hello.c中涉及算术操作i++,该操作使用addl指令实现。代码中的示例如下:
![]() | ![]() |
3.3.4 关系操作
hello.c对应的汇编语言中的关系操作主要通过比较测试指令来实现。代码中的示例如下:
C语言 | 汇编语言 |
![]() | ![]() |
![]() | ![]() |
3.3.5 数组操作
对于int main(int argc, char *argv[]),参数argc表示输入字符串的个数,而参数argv是一个指针数组,每个元素指向一个输入字符串。
如下图所示,程序向printf传递了参数argv[1]和argv[2],同时向atoi函数传递了参数argv[3]。为了访问这些参数,采用了地址加偏移量的方式来定位数组中的元素。具体地,存放在寄存器rsi的参数argv[]的首地址存储在%rbp - 32的位置,而存放在寄存器rdi的参数argc存储在%rbp - 20的位置。通过%rbp - 32存储的argv[]的首地址加上偏移量来访问argv[1]、argv[2]、argv[3]。
![]() |
![]() |
![]() |
3.3.6 控制转移
在hello.c中,if条件和for循环语句涉及到控制转移。程序通常使用cmpl(比较)指令与je(相等跳转)以及jle(小于等于跳转)等条件跳转指令来实现条件判断和控制流转移。代码示例如下:
![]() | ![]() |
![]() |
3.3.7 函数操作
汇编语言使用call指令来调用函数,而使用ret指令来返回。函数参数通常保存在寄存器和栈中。
在本文代码中,hello.c中依次调用了printf、atoi、sleep等函数。其中,参数argv[1]、argv[2]和argv[3]分别被放入寄存器rsi、rdx和rcx中,参数argv[4]被放入寄存器rdi中。函数的返回值通常被保存在寄存器rax中,然后被传递给下一个函数。例如,函数atoi的返回值被放入寄存器rdi中,作为函数sleep的参数。
![]() |
![]() |
3.4 本章小结
本章将对生成的汇编程序文本 hello.s 进行深入分析,通过解读其中的汇编指令,探讨汇编器是如何将C语言中的数据和操作转换为相应的汇编语言指令的。这一过程为不同高级语言的不同编译器之间建立了通信桥梁,使它们能够生成最终的机器可执行的二进制指令代码。
第4章 汇编
4.1 汇编的概念与作用
汇编是一个将汇编语言代码转换为机器语言代码的过程。首先,程序员编写汇编语言代码,这些代码是以助记符和符号表示的指令。然后,汇编器(as)读取这些代码并进行翻译。翻译过程中,汇编器将每一条汇编语言指令转换为对应的机器语言指令,这是计算机可以直接执行的二进制代码。
在这个翻译过程中,汇编器还会处理符号和地址等信息,并生成一种可重定位目标程序的格式。这意味着生成的二进制目标文件可以在链接阶段被调整和组合,最终形成一个可执行文件。汇编器的输出是一个二进制目标文件,其中包含了机器语言指令和必要的重定位信息,以便在链接和加载阶段进行进一步处理。
4.2 在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -c -o hello.o hello.s
1.gcc:调用 GNU 编译器。
2.-m64:生成 64 位代码。
3.-no-pie:不生成位置无关的可执行文件。
4.-fno-PIC:不生成位置无关代码。
5.-c:只编译,不链接。
6.-o hello.o:指定输出文件为 hello.o。
7.hello.s:输入汇编源文件。
过程如下:
4.3 可重定位目标elf格式
在 x86-64 Linux 系统中,可重定位目标程序使用可执行和可链接格式(ELF)进行存储。典型的 ELF 可重定位目标文件格式如下图所示。
通过readelf -a hello.o查看 hello.o的各部分信息:
\
显示内容如下:
- ELF头:
ELF头以16字节的Magic序列开头,该序列描述了文件生成系统的字节大小和字节顺序。其余部分包括ELF头的大小、目标文件类型、机器类型、节头表的文件偏移,以及节头表中条目的大小和数量等信息,这些信息有助于链接器解析和解释目标文件。如下图所示
- 节头部表
节头部表是 ELF 文件中的一个部分,它包含了关于各个节(section)的信息。每个节都对应着文件中的一个段落,用于存储不同类型的数据,比如代码、数据、符号表等。节头部表记录了每个节的名称、类型、在文件中的偏移量、大小、在内存中的虚拟地址等信息。如下图所示:
- 重定位节:
汇编器在遇到对位置未知的目标引用时,会生成一个重定位条目,其中包含有关符号的偏移量、基址信息、类型以及符号名称等内容。这样做是为了在链接器后续将目标文件合并成可执行文件时,能够正确地修改引用,使程序能够正确地执行。
- 符号表
符号表按顺序记录了程序中出现的各种符号及其重定位值、大小、类型、是否为全局符号、是否可见、位置、名称等信息。在重定位前,符号表中的初始化值通常为0,而符号的Ndx为UND表示该符号需要经过链接才能从外部获取定义。
-
- Hello.o的结果解析
首先,使用objdump反汇编hello.o并将结果保存在hello.o.s中
对比hello.o.s和hello.s,可发现有如下不同:
1.两者在进制转换上有区别:
hello.s用的是十进制立即数,hello.o.s用的是十六进制数
hello.s | hello.o.s |
![]() | ![]() |
2.控制转移:
hello.s用的是.L1、.L2助记符,hello.o.s用的是指令偏移地址
hello.s | hello.o.s |
![]() | ![]() |
3.函数调用:
hello.s文件中函数调用直接引用函数的名称,hello.o.s文件中,函数调用地址是下一条程序的地址。
hello.s | hello.o.s |
![]() | ![]() |
机器语言构成:机器语言是由一系列二进制指令组成的,每条指令都有特定的操作码(Opcode),用来表示执行的操作类型,以及操作数(Operand)用来表示操作所需的数据或地址。
机器语言与汇编语言映射关系:在汇编语言中,每个助记符代表了对应的机器语言指令,而符号则表示地址、标签或变量等。汇编语言的语法结构与机器语言指令一一对应。
二者不一致:上文中已在对比hello.s和hello.o.s的过程中说明
4.5 本章小结
本章详细介绍了通过汇编器将汇编代码文件hello.s转换为可重定位目标文件hello.o的过程。通过使用readelf工具,我们查看了hello.o文件的ELF内容,包括各个部分的信息。接着,我们利用反汇编工具objdump生成了反汇编文件hello.o.s,并将其与原始汇编文件hello.s进行比较,以深入探究汇编的具体作用和翻译过程。
第5章 链接
5.1 链接的概念与作用
链接器(ld)是将各个文件中的代码和数据综合起来的工具,通过符号解析和重定位等过程,最终生成一个可在程序中加载并运行的可执行目标文件的过程。
链接的作用是将多个可重定位目标文件合并,以生成可执行目标文件,这使得分离编译成为可能。在程序修改后,无需重新编译整个工程,而是仅需编译修改的文件,从而极大地方便了大型应用程序的修改和编译。此外,链接还有利于构建共享库,可以将常用函数(如 printf 函数)存储在共享库中,在链接时与源程序进行合并,从而节省空间。
5.2 在Ubuntu下链接的命令
过程如下:
5.3 可执行目标文件hello的格式
通过readelf -a hello查看:
结果如下:
- ELF头
查看hello文件的 ELF 头,并与hello.o的ELF 头进行比较,可以发现一些关键的变化。首先,程序类型从可重定位文件 REL 变为可执行文件 EXEC。其次,入口点地址从 0 变为 0x4010f0。除此之外,ELF 头中的其他参数信息也得到了填充和完善。
hello.o | hello |
![]() | ![]() |
- 节头部表:
链接器将各个文件中的段合并,并重新分配计算了相应节的类型、位置、大小等信息。每个节的地址也从0开始重新分配。可以观察到,.text节的起始地址为0x4010f0,这符合ELF头中的程序入口地址。
hello.o | hello |
![]() | ![]() |
- 符号表
符号表的内容在合并后有所扩充。
hello.o | hello |
![]() | ![]() |
- 重定位条目:
将hello.o和hello的重定位条目比较,二者在连接后有较大的差异
hello.o | ![]() |
hello | ![]() |
- 程序头
hello新增了程序头部分,用来描述磁盘上可执行文件的内存映射关系
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,具体显示部分如下图,可知hello起始于虚拟地址0x401000,和上面init的虚拟地址相同,除了下面的例子,还能发现其他hello各节的起始地址与ELF文件中的虚拟地址也一一对应。
5.5 链接的重定位过程分析
用objdump -d -r hello反编译hello,并将结果保存在hello_e.s中,
对比hello_e.s和hello.o.s,区别如下:
- hello对应的反汇编文件比hello.s新增了很多函数。如put, printf, getchar, atoi, exit, sleep等,具体如下图所示
- hello对应的反汇编文件新增了.init节和.plt节
- hello的反汇编文件中函数地址为虚拟地址,而hello.o.s中的为相对地址。
hello_e.s | ![]() |
hello.o.s | ![]() |
链接的过程如下:
连接过程将编译器生成的目标文件和库文件结合,生成可执行文件。编译器生成的目标文件(如hello.o)包含对库函数的引用(如printf、exit等),但不包含这些函数的具体实现。连接器在链接阶段将这些引用解析为实际的库函数实现,并将其包括在可执行文件中。因此,hello的反汇编文件中新增了许多函数。反汇编文件中新增的.init节包含初始化代码,在main函数之前执行。.plt节(Procedure Linkage Table)用于动态链接,包含跳转到动态库函数的地址表。目标文件使用相对地址表示位置,而可执行文件使用虚拟地址,这些地址在程序加载时映射到实际的物理内存地址,提供独立的内存空间。这解释了为什么函数地址在hello的反汇编文件中是虚拟地址,而在hello.o.s中是相对地址。
重定位过程如下:
重定位是链接器(ld)在符号解析后,将符号引用与定义关联,并获得代码和数据节的大小信息。链接器合并输入模块,为每个符号分配运行时地址。重定位分两步进行:首先,重定位节和符号定义。链接器合并相同类型的节,为每条指令和全局变量赋予唯一的运行时地址。其次,重定位符号引用。链接器根据重定位条目的节偏移量和修正值,结合起始地址,计算出符号的运行时地址并更新符号引用。
-
- hello的执行流程
(以下格式自行编排,编辑时删除)
从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)如下:
<ld-2.31.so!_dl_start>
<ld-2.31.so!_dl_init>
<hello!_start>
<libc-2.31.so!__libc_start_main>
<hello!main>
<hello!printf@plt>
<libc-2.31.so! printf >
<hello!atoi@plt>
<libc-2.31.so! atoi >
<libc-2.31.so! strtoq >
<hello!sleep@plt>
<libc-2.31.so! sleep >
<libc-2.31.so! nanosleep >
<libc-2.31.so! clock_nanosleep >
<libc-2.31.so! _IO_file_xsputn>
<hello!getchar@plt>
<libc-2.31.so!getchar>
-
- Hello的动态链接分析
在编译过程中,静态链接生成了部分可执行文件hello,共享库代码和数据并未直接整合其中。程序运行时,动态链接器介入,负责将共享库代码和数据加载并链接到hello中,实现如printf、sleep、atoi等函数的调用。由于共享库函数的地址在编译时未知,需要动态链接器在程序运行时解析。Linux系统采用延迟绑定策略,将函数地址的解析推迟至首次调用时。延迟绑定主要通过两个数组实现:PLT(Procedure Linkage Table)和GOT(Global Offset Table)。PLT是一个16字节大小的条目数组,其中PLT[0]指向动态链接器,其余条目用于调用具体函数。GOT是一个8字节大小的地址数组,前两个条目用于动态链接器解析,GOT[2]指向动态链接器在ld-linux.so模块中的入口点,其余条目在运行时解析,与被调用的函数地址相对应。
5.8 本章小结
本章解释了链接的概念和作用,通过分析hello的ELF格式、虚拟地址空间、重定位过程、执行流程和动态连接,详细探讨了可重定位文件hello.o是如何链接生成可执行文件hello的。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程的作用在于提供两个关键的假象给应用程序:独立的逻辑控制流和私有的地址空间。没有了进程,像现代计算机系统这样庞大的系统是无法设计的。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash是一个交互式的应用程序,充当用户与操作系统内核之间的桥梁。它负责处理进程创建、程序加载运行、前后台控制、作业调度以及信号发送与管理等任务。
在执行可执行文件hello时,Shell-bash的处理流程如下:
1) 用户在Shell命令行输入命令:./hello。
2) Shell命令行解释器解析命令,构造命令的参数(argv)和环境变量(envp)。
3) 调用fork()函数创建一个子进程,将hello的代码段、数据段、以及未初始化数据段等内容加载到当前进程的虚拟地址空间中。
4) 子进程调用execve()函数,将自己的内存空间替换为hello程序的内存空间,并跳转到hello程序的入口点(例如_start函数),开始执行hello程序。
6.3 Hello的fork进程创建过程
Shell调用fork函数创建子进程时,子进程会得到与父进程相同但独立的用户级虚拟地址空间副本,包括代码和数据段、堆、共享库以及用户栈。这使得子进程可以在独立的环境中运行,不受父进程影响。同时,子进程会继承父进程的打开文件描述符,但拥有独立的进程PID,使得操作系统能够区分和管理每个进程。
6.4 Hello的execve过程
子进程调用execve函数时,在当前进程的上下文中加载并运行程序hello。execve函数有三个参数,分别是可执行目标文件名filename、参数列表argv和环境变量列表envp。加载器首先会删除子进程现有的虚拟内存,并创建一组新的代码、数据、堆和栈段,其中栈和堆被初始化为零。然后,通过内存映射将代码和数据段初始化为可执行文件中的内容。最后,加载器设置PC指向_start地址,以调用hello中的main函数。在加载过程中,并没有任何数据从磁盘复制到内存,直到CPU引用一个被映射的虚拟页时才会进行复制。这种延迟复制的机制使得加载过程更加高效。
6.5 Hello的进程执行
系统为了提高效率,会运行多个进程,这些进程轮流使用处理器。每个进程都有一个上下文,其中包含内核重新启动进程所需的状态,如寄存器、程序计数器和用户栈等。为了提供一个可靠的进程抽象,处理器需要限制应用程序的指令和访问地址空间的范围。处理器使用一个模式位来设置运行模式,以限制处理器的访问范围。当执行hello进程时,由于从磁盘中读取数据的时间较长,会触发陷阱异常,将处理器切换到内核模式。此时系统进行进程调度,重新启动先前被抢占的进程。当另一个进程运行了足够长的时间后,系统再次进行进程调度,抢占执行hello进程。这时,hello进程由内核态切换到用户态。
6.6 hello的异常与信号处理
异常有如下几类:
信号种类如下:
下面是分析hello对各种信号的处理:
1. 乱按键盘,触发中断,hello进程并没有接收到信号,乱序输入的字符串被缓存在stdin中
2. 按Ctrl + Z
hello进程接收到SIGSTP信号被挂起:
使用ps查看后台进程,发现hello的PID是2721
使用jobs查看当前作业,此时hello的job号为1:
接下来查看进程树:
输入fg向进程发送信号SIGCONT将其调回前台继续运行:
最后输入kill向进程发送SIGKILL信号,终止hello进程:
3. 按Ctrl + C,进程因收到SIGINT信号而终止。
6.7本章小结
本章介绍了进程的概念和作用,并简要介绍了shell的作用及处理流程。以hello程序的执行为例,分析了shell通过fork和execve函数创建进程并运行程序hello的过程,以及可能发生的异常和信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址是由程序生成的与段相关的偏移地址部分,在hello.o文件中,这种地址以相对偏移地址的形式表示。
线性地址:线性地址是逻辑地址与物理地址之间的一个中间层。其地址空间是一个由非负整数组成的有序集合,如果这些整数连续排列,则称其为线性地址空间。在hello段中,偏移地址与其基地址组合后生成了一个线性地址。
虚拟地址:虚拟地址是操作系统分配给应用程序使用的地址。它被组织成一个存储在磁盘上的数组,数组由N个连续的字节大小的单元组成。在hello中,main函数的段偏移量就是虚拟地址。
物理地址:物理地址是加载到内存地址寄存器中的地址,它是数据在内存单元中的实际存放位置,用于CPU定位对应的物理内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel平台下,逻辑地址是由段选择器(selector)和偏移量(offset)组成的。这种逻辑地址的表示形式通常是selector。在实模式下,段选择器存储在代码段寄存器 CS 中,而偏移量存储在指令指针寄存器 EIP 中。为了将逻辑地址转换为线性地址,系统使用了段式管理。在这种管理方式下,首先从逻辑地址中获取到段选择器和偏移量。接着,通过段选择器从全局描述符表(Global Descriptor Table,GDT)中获取相应的段描述符。段描述符中包含了与该段相关的信息,包括段的基址。然后,将段基址与偏移量相加,得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
系统中,虚拟内存被划分为大小固定的块,称为虚拟页,而物理内存也被划分为相同大小的块,称为物理页帧。虚拟页面的状态可以分为三种情况:未分配的虚拟页面,这些页面尚未被系统分配使用,因此不含任何相关数据,也不占用磁盘空间。已缓存的虚拟页面,这些页面已经被分配,并且当前已经缓存在物理内存中。它们包含相关的数据,并且可以被快速访问,因为它们位于物理内存中。
未缓存的虚拟页面,这些页面已经被分配,但尚未被缓存到物理内存中。它们可能被暂时地存储在磁盘上,只有在需要时才会被调入物理内存。未缓存的页面需要从磁盘中加载到物理内存中才能被访问。如下图所示:
虚拟内存通过页表将虚拟页映射到物理页,硬件通过读取页表实现地址翻译。页表存储了虚拟页和物理页的对应关系,当处理器访问内存时,MMU根据虚拟地址查找页表,找到对应的物理页号,并将其与偏移量组合成物理地址。
内存管理单元(MMU)利用CPU中的页表基址寄存器来定位进程对应的页表。通过虚拟页号(VPN),MMU选择对应的页表条目(PTE),然后将其中的物理页号(PPN)与虚拟页面偏移(VPO)相联合,得到相应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
Core i7处理器采用四级页表层次结构,虚拟地址的46位被分为四个片段,每个片段占用9位。这些片段用作访问页表时的偏移量。在处理器中,控制寄存器CR3存储着第一级页表的物理地址。当访问虚拟地址时,首先使用虚拟地址的第一部分VPN1获取第一级页表项PTE,PTE中包含了第二级页表的基地址。接着,利用VPN2获取第二级页表项,以此类推,直到获取到最底层的第四级页表项,从而得到物理地址。最后,将得到的物理地址与虚拟地址的偏移部分VPO组合,得到最终的物理地址。
![]() | ![]() |
7.5 三级Cache支持下的物理内存访问
当CPU发送虚拟地址时,MMU会进行地址翻译,将其转换为物理地址。如果这个物理地址不在TLB(Translation Lookaside Buffer)中,那么MMU就会在缓存中寻找。缓存的结构一般包含标记位(Tag)、组索引位(Index)以及偏移位(Offset)。MMU通过这些位来定位数据,分为三个步骤:组索引、行匹配和字抽取。如果命中了缓存,就直接返回数据;如果未命中,则会依次访问L2缓存、L3缓存,判断是否命中。如果在某一级缓存中命中了数据,就将其传送给CPU,并更新各级缓存。
7.6 hello进程fork时的内存映射
在新进程创建时,会记录当前进程的内存管理结构(mm_struct)、区域结构和页表的原样副本。同时,进程中的每个页面会被标记为只读,并且区域设置为私有的写时复制。当任何一个进程试图进行写操作时,会触发保护故障程序,创建新的页面,并更新页表条目指向新的副本。在新页面上恢复可写权限后,保护故障处理程序返回,进程可以正常执行。
7.7 hello进程execve时的内存映射
加载器在执行hello程序时,首先会清除已存在的用户区域,然后为hello程序的代码、数据、bss(未初始化数据段)和栈创建新的区域结构,并将其设为私有的、写时复制的。代码和数据区域会映射到hello文件中的.text区和.data区。bss区域会映射到一个匿名文件,而栈和堆的初始长度都设置为零。此外,动态链接程序(如libc.so)会被映射到用户虚拟地址空间中的共享区域内。最后,加载器会设置该进程上下文的程序计数器(PC)指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
DRAM缓存未命中通常被称为缺页,当MMU通过有效位发现所需数据未被缓存时,就会触发缺页异常。内核中的异常处理程序会选择牺牲一页,然后处理牺牲页以存储新的数据。处理完毕后,异常处理程序会将所需的虚拟页从磁盘复制到牺牲页中,并更新页表条目。接着,异常处理程序会返回,重新执行导致缺页的指令。此时,由于页面已经被加载到内存中,地址翻译过程会继续进行,而不会再次触发缺页异常。
缺页 | 处理 |
![]() | ![]() |
7.9动态存储分配管理
7.9.1 堆
动态内存分配器管理着进程中的一个虚拟内存区域,称为堆。分配器将堆视为具有连续地址的一系列不同大小的内存块集合。这些块分为两种类型:已分配块用于应用程序,空闲块用于将来的内存分配。
7.9.2 隐式空闲链表管理
每个空闲块的头部和尾部各占4个字节,用于记录块的大小和状态信息。头部和尾部的高29位用于存储块的大小,最低3位表示块的状态:000表示空闲,001表示已分配。当应用程序请求分配k字节的内存时,分配器会在空闲链表中寻找一个合适的空闲块。分配器有三种选择策略:首次适配、下一次适配和最佳适配。当释放已分配的块时,分配器可以通过使用边界标记合并相邻的空闲块。
7.9.3 显式空闲链表管理
操作系统通常采用显式空闲链表来管理内存,维护多个空闲链表,每个链表包含大致相同大小的内存块。分配器维护一个空闲链表数组,在分配内存时只需在相应的链表中查找,而在释放内存时有两种策略:
1. 简单分离存储
在这种策略下,内存块不会被合并或分割,每个块的大小等于其类别中最大块的大小。分配和释放操作的时间复杂度都是常数级,但这种方法的空间利用率较低。
2. 分离适配
每个类别的空闲链表包含各种大小的块。当分配一个块后,分配器会将其分割,并将剩余部分插入到相应类别的空闲链表中。这种方法在搜索时间和空间利用率之间取得了良好的平衡,GNU C库中的malloc包就是采用这种方法的。
7.10本章小结
本章对hello程序的存储管理进行了详细分析,涵盖了存储器地址空间、分段管理和分页管理的机制,以及在TLB和四级页表支持下从虚拟地址到物理地址的转换。此外,还讨论了在三级缓存体系下的物理内存访问。通过研究hello进程,探讨了fork和execve函数的内存映射原理。最后,还对缺页故障和动态内存分配管理进行了说明。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
在Linux系统中,文件的概念是核心思想之一。它通过将I/O设备视为文件,使得Linux内核能够提供一个简洁且基础的Unix I/O接口。这种方法实现了对所有设备进行统一的输入输出操作,使得处理方式更为一致和便捷。
8.2 简述Unix IO接口及其函数
Unix I/O接口提供了一组系统调用,这些系统调用允许用户空间的程序与操作系统内核进行交互,以执行各种输入输出操作。这些操作包括打开文件、读取数据、写入数据、关闭文件等。Unix I/O接口的函数是构建在Unix哲学之上的,它们简单、一致且功能强大。
基本函数以及对应功能如下:
- open():打开一个文件,并返回一个文件描述符。文件描述符是一个整数,用于在后续操作中标识文件。
语法:int open(const char *pathname, int flags, ...);
- close():关闭一个文件描述符,释放与该文件相关的资源。
语法:int close(int fd);
- read():从打开的文件中读取数据。
语法:ssize_t read(int fd, void *buf, size_t count);
- write():向打开的文件写入数据。
语法:ssize_t write(int fd, const void *buf, size_t count);
- lseek():改变文件描述符的文件位置偏移量。
语法:off_t lseek(int fd, off_t offset, int whence);
- stat():获取文件状态信息,如文件大小、权限、所有者等。
语法:int stat(const char *path, struct stat *buf);
- fstat():与stat()类似,但是操作的是文件描述符。
语法:int fstat(int fd, struct stat *buf);
- chmod():改变文件的权限。
语法:int chmod(const char *path, mode_t mode);
- chown():改变文件的所有者和组。
语法:int chown(const char *path, uid_t owner, gid_t group);
- unlink():删除文件。
语法:int unlink(const char *pathname);
- rename():重命名文件或目录。
语法:int rename(const char *oldpath, const char *newpath);
- access():检查文件的访问权限。
语法:int access(const char *pathname, int mode);
8.3 printf的实现分析
printf
函数通过可变参数列表接收不定数量的输入参数。在函数体内,使用 va_list
类型来追踪和访问这些参数。va_list
实际上是一个指向参数列表的指针,它通过计算从格式化字符串 fmt
的地址开始的位置来定位第一个可变参数。vsprintf
函数是 printf
的核心,它根据 fmt
中的格式化指令,将相应的参数转换成字符串形式,并存储在缓冲区 buf
中。例如,如果 fmt
中包含 %x
,则会将相应的整型参数转换成十六进制格式的字符串。一旦 buf
被填充了格式化后的字符串,printf
函数通过 write
系统调用将这些字符输出。在 Linux 系统中,write
调用通常带有两个参数:文件描述符和要写入的缓冲区。在标准输出的情况下,文件描述符通常是 1。write
系统调用是用户空间程序与操作系统内核交互的一种方式。在底层,它通过触发中断(如 int 0x80
或 syscall
)来实现系统调用,将控制权从用户空间转移到内核空间。在内核中,系统调用的处理程序会根据传递的参数执行相应的操作。对于 write
调用,内核将缓冲区的内容写入指定的文件描述符指向的设备,这通常是控制台或终端。字符显示驱动子程序是整个流程的最后一环。它负责将字符从内核传递给硬件,最终显示在用户的屏幕上。这个过程包括将 ASCII 字符转换为字模库中的字模,然后将这些字模映射到显示内存(VRAM)中的相应位置。显示芯片按照刷新频率读取 VRAM,并生成控制液晶显示器显示每个像素点的 RGB 信号。
8.4 getchar的实现分析
实现与分析如下:
首先,getchar
函数内部定义了一个静态缓冲区buf
,用于暂存从键盘读取的字符。由于buf
是静态的,它在函数调用间保持数据,这意味着getchar
可以持续读取输入直到遇到换行符。函数中还定义了一个静态指针bufp
,指向buf
中的当前读取位置,以及一个整数n
,表示缓冲区中已存储的字符数。
当n
为0时,表示缓冲区为空,此时getchar
会通过read
系统调用从文件描述符0(标准输入)读取数据到buf
中。读取的字符数存储回n
,并将bufp
重置为指向buf
的开始。随后,getchar
检查n
是否大于等于0,如果是,它将返回缓冲区中的下一个字符,并更新bufp
指向下一个字符位置。如果缓冲区中没有更多字符(n
小于0),则返回EOF,表示输入已结束。整个过程是异步的,因为用户可以在任何时候输入字符,而getchar
则在需要时从缓冲区中读取这些字符。当用户按下回车键时,换行符被添加到缓冲区,getchar
读取到它后会返回,表示一次输入的结束。
8.5本章小结
本章简述了Linux的IO设备管理,介绍了Unix IO接口及其函数,并对printf和getchar函数的实现进行分析。
结论
当执行一个简单的 "Hello, World!" 程序时,计算机系统经历了以下过程:
1. 预处理:hello.c 被转换成 hello.i,包含函数库声明。
2. 编译:hello.i 被转换成汇编语言的 hello.s 文件。
3. 汇编:hello.s 被转换成机器码,生成 hello.o。
4. 链接:hello.o 与库文件链接,形成可执行文件 hello。
5. 执行:用户运行 ./hello,创建进程并执行程序。
6. 内存管理:MMU 将虚拟地址映射到物理地址。
7. 动态内存:printf 可能使用 malloc 动态分配内存。
8. 信号处理:程序响应如 Ctrl + C 的中断信号。
9. 进程回收:程序结束,系统回收资源。
创新理念:未来的计算机系统设计可以集成自适应编译器、智能内存管理、强化的安全性和能效优化,以提升性能和用户体验。
感受:hello,千里之行的第一步,却是上一次万里征程的结晶,任重道远。
附件
文件名称 | 文件作用 |
hello.c | 源代码文件 |
hello.i | 预处理得到的代码文件 |
hello.s | 汇编代码文件 |
hello.o | 可重定位目标文件 |
hello | 可执行目标文件 |
hello.o.elf | hllo.o的ELF |
hello.o.s | hello.o反汇编后的代码 |
hello.elf | hello的ELF |
hello_e.s | hello的反汇编代码 |
参考文献
[1] Tanenbaum, A. S., & Bos, H. (2015). 深入理解计算机系统 (原书第3版). 机械工业出版社.