关注

程序人生-Hello‘s P2P

第1章 概述

1.1 Hello简介

1.1.1 P2P(Program to Process)

        在Linux终端输入gcc -o hello hello.c命令后,首先预处理器(cpp)读取源程序文件hello.c,并根据预处理命令执行向源代码中插入头文件、展开宏等操作得到预处理文件hello.i;然后编译器(ccl)将hello.i编译成汇编语言程序hello.s;然后汇编器(as)将hello.s翻译成二进制的机器语言程序hello.o;最后,链接器(ld)将可重定位目标文件hello.o与其运行时所依赖的其他目标文件或库文件链接并合并打包成可执行文件hello;最后,用户在终端输入命令./hello,shell调用fork函数创建子进程,并在子进程中执行hello程序。

1.1.2 020(Zero to Zero)

        用户在终端输入./hello命令时,shell调用fork函数创建一个子进程,然后调用execve函数加载可执行文件hello,将其代码段、数据段等映射到内存中,并建立虚拟地址到物理地址的映射关系。

        然后,子进程进入就绪队列,等待被调度。调度器根据时间片轮转等算法将 CPU分配给该进程。CPU执行取指、译码、执行等流水线操作,逐条执行程序指令直至程序的主函数结束。此时进程发出退出请求,内核执行清理工作,关闭文件描述符、释放内存资源等,将其从系统中移除,进程最终进入终止状态。

1.2 环境与工具

1.2.1 硬件环境

处理器:Ryzen 7 7735H @3.20GHz;内存:16GB RAM

1.2.2 软件环境

Windows10 64位; Vmware 17; Ubuntu22.04

1.2.3 开发工具

VS Code/gcc/as/ld/edb/readelf/

1.3 中间结果

表1 中间文件
文件名   作用

hello.i

预处理后的文件,头文件和宏已经被展开

hello.s

汇编语言文件,包含描述机器指令的文本代码

hello.o

可重定位目标文件,包含机器语言指令

hello

最终的可执行文件

1.4 本章小结

        本章首先总结了hello程序的P2P和020过程,然后介绍大作业后续实验的实验环境和开发工具。

第2章 预处理

2.1 预处理的概念与作用

        预处理指在C语言编译过程的第一个阶段中,预处理器根据源代码中的预处理命令对源代码进行文本级处理。

预处理包含以下5种作用:

        1. 宏展开

        在代码中展开通过#define命令定义的宏。例如,在源代码开头定义一个宏#define NUM 100,编译器在编译时会将表达式中的NUM自动替换为100。

        2. 文件包含

        用头文件指令#include将头文件直接插入源代码的文本中。

        3. 条件编译

        使用预处理指令控制源代码编译行为,常见预处理指令及其作用如表2所示。

表2 预处理指令

        4.删除注释

        删除所有通过//和/**/进行的注释。

        5.行拼接

        将使用反斜杠\分行的表达式拼为一行。

2.2在Ubuntu下预处理的命令

        在Ubuntu终端输入命令“gcc -m64 -no-pie -fno-PIC -E -P hello.c -o hello.i”生成hello.c经预处理后的hello.i文件。

图1 预处理结果

 2.3 Hello的预处理结果解析

        hello.c的源代码如下:

1.	// 大作业的 hello.c 程序
2.	// gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC hello.c -o hello
3.	// 程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等。
4.	// 可以 运行 ps  jobs  pstree fg 等命令
5.	// 秒数=手机号%5
6.	
7.	#include <stdio.h>
8.	#include <unistd.h>
9.	#include <stdlib.h>
10.	
11.	int main(int argc,char *argv[]){
12.	int i;
13.	
14.	if(argc!=5){
15.	printf("用法: Hello 学号 姓名 手机号 秒数!\n");
16.	exit(1);
17.	}
18.	for(i=0;i<10;i++){
19.	printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);
20.	sleep(atoi(argv[4]));
21.	}
22.	getchar();
23.	return 0;
24.	}

        预处理器会将源代码中通过//注释的文本删去,并在main函数前直接插入头文件stdio.h、unistd.h和stdlib.h,效果如图2、3所示。

图2 预处理插入头文件
图3 预处理删除注释

2.4 本章小结

        本章首先概述了C语言预处理的概念及其5种作用,然后通过具体实验验证了预处理操作会插入头文件和删除注释等功能。

第3章 编译

3.1 编译的概念与作用

        编译是指编译器将高级语言编写的源代码转化为机器代码的过程。

        编译过程有以下4种作用:

        1.代码转换

        编译器通过词法分析、语法分析、语义分析将源代码转换为机器代码。以int a = b + c;为例,首先,编译器将该表达式分解为代码语言的基本单元即词素并输出一个词素流(结果如表2所示);然后,编译器根据语法规则将词素流构建成一个抽象语法树(AST)以描述表达式的结构;然后,编译器进行错误检测;最后,编译器将高级语言代码翻译为机器语言,而在这之前编译器也会尝试对代码进行优化。

表3 表达式词素类型及内容

          2.代码优化

        编译器通过使用循环优化、常量折叠、死代码消除、内联展开等方法减少程序运行时的循环次数和内存访问以提高程序的性能。

        3.错误检测

        编译器通过语法分析和语义分析来检测源代码是否语法正确同时各个变量是否已经定义且类型匹配。

        4.平台适配

        对于同一个源代码,不同编译器可将其编译为不同平台上的机器代码,提高了代码的可移植性。

3.2 在Ubuntu下编译的命令

        在hello.i文件同目录下打开控制台输入gcc -S -m64 -no-pie -fno-PIC hello.i -o hello.s命令生成hello.s文件。

图4 编译结果

3.3 Hello的编译结果解析

3.3.1 数据、变量赋值

        如图5所示,字符串常量存储在.rodata段,同时由于没有出现.data和.bss,可以判断源代码中没有定义全局变量。

图5

         在程序执行main函数时,传入的参数argc和argv[]分别通过寄存器%edi 和%rsi传递到当前栈帧中偏移量为-20和-32的位置。随后,程序将栈帧中-20(%rbp) 的值与立即数5进行比较,对应源代码中的条件判断argc != 5(图6)。同时,如图7所示,程序跳转至.L2处将立即数0赋值给-4(%rbp),再结合源代码for循环中的表达式i = 0可知局部变量i被存储在栈帧中偏移-4的位置,即-4(%rbp)。

图6
图7

 3.3.2 算术操作、关系操作

        如图8、9、10所示,程序将立即数1加到-4(%rbp)中,对应源代码表达式i++;程序将立即数5与-20(%rbp)的值比较,两者相等时跳转至.L2,对应源代码表达式argc != 5;程序将立即数9与-4(%rbp)的值比较,当-4(%rbp)的值小于等于9时跳转.L4,对应源代码中i < 10。

图8
图9
图10

 3.3.3 数组、指针操作

        如图11所示,在汇编代码.L4处,程序三次将当前栈帧地址传入寄存器%rax,然后将%rax的值分别加上24、16和8,进行三次寄存器间接寻址将三个数据分别传入寄存器%rcx、%rdx和%rsi,对应源代码中获取argv[3]、argv[2]和argv[1],同理,如图12所示,程序先将当前栈帧传入%rax,再加上32并进行寄存器间接寻址,对应源代码获取argv[4]。

图11
图12

 3.3.4 控制转移

        对于if (argc != 5)条件判断语句,程序使用cmpl命令将-20(%rbp)中数据的低32位与立即数5进行比较,两者相等时跳转至.L2处(图13)。

图13

        对于源代码中for循环,其行为与如下代码的行为相同 

1.	i = 0;  
2.	while (i < 10)   
3.	{  
4.	    printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);  
5.	    sleep(atoi(argv[4]));  
6.	    i++;  
7.	}  

        因此,编译器将for循环作为while循环进行翻译。如图14所示,程序首先在.L2处将-4(%rbp)置0,即给变量i赋值0,然后无条件跳转至.L3;程序在.L3处比较-4(%rbp)的值与立即数9的大小,小于等于9时跳转至.L4;程序在.L4顺序执行,对于源代码中调用printf函数和sleep函数的部分。

图14

3.3.5 函数调用

        源代码中main函数共调用了printf、exit、sleep、atoi和getchar五种函数。

        对于printf函数,main函数第一次调用printf时,程序将.LC0的地址传入寄存器%rdi的低32位并作为参数调用puts(图15);main函数第二次调用printf时(图16),将.LC1的地址传入%rdi的低32位并与寄存器%rcx、%rdx和%rsi中的值一起作为参数调用printf,同时将%eax清零以保存printf函数的返回值。

图15
图16

        对于exit函数,程序先将立即数1传入%rdi低32位,再调用exit(如图17),调用后程序直接退出。  

图17

        对于sleep(atoi())嵌套,程序首先以%rax中的地址间接寻址,将数据存入%rax中,然后再将%rax的数据存入%rdi中作为参数调用atoi,%rax保存atoi函数的返回值,然后程序再次将%rax低32位的数据保存在%edi中作为参数调用sleep(如图18)。 

图18

        对于getchar函数,程序直接调用getchar,没有参数传入(图19)。 

图19

3.4 本章小结

        本章首先概述了编译的概念和作用,然后结合hello.s文件从变量赋值、算术和关系运算、数组和指针操作、条件转移及函数调用五方面分析汇编代码中如何描述计算机寄存器和内存的行为,为下一章分析hello.o的反汇编代码做铺垫。

 

第4章 汇编

4.1 汇编的概念与作用

        汇编指汇编器(as)将汇编语言程序翻译成机器语言指令,并将这些指令以可重定位目标程序的格式打包保存在后缀为.o的二进制文件中。

        汇编通过生成机器语言实现对计算机硬件的精准控制和对程序的性能优化。

4.2 在Ubuntu下汇编的命令

        在终端中输入命令gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o后得到hello.o文件(图20)。

图20 汇编结果

4.3 可重定位目标elf格式

4.3.1 ELF

        如图21所示,Magic为ELF文件的魔数,7f 45 4c 46表示这是一个ELF文件;类别和数据分别指明该ELF文件为64位,采用补码编码和小端序的存储方式;类型为REL,代表此目标文件为可重定位目标文件,且由于没有进行链接,程序的入口点地址为0x0;ELF头的其他项表示ELF头大小为64字节,ELF文件没有程序头项而有14个节,每个节的大小为64字节。

图21 hello.o的ELF头

 4.3.2 节头表

        如图22所示,节头表包含了14个节的名称、类型、地址、偏移量和大小等信息。其中.text节存储hello.o的机器代码,大小为0x99字节;.rodata节存储程序的字符串常量,大小为0x40字节;.data和.bss节的大小为0,表示程序没有初始化和未初始化的全局变量;.symtab为符号表,大小为0x198字节;.rela.text和.rela.eh_frame存储重定位信息,大小分别为0xc0字节和0x18字节。

图22 hello.o的节头表

4.3.3 重定位表

        如图23所示,重定位表包含了重定位条目的偏移量、信息、类型、符号值、名称和加数。其中偏移量表示需要被调整的位置;信息包含符号索引和重定位类型;类型分为R_X86_64_32、R_X86_64_PLT32和R_X86_64_PC32,分别表示绝对32位地址、PC相对地址且指向PLT和PC相对地址不指向PLT;符号值为当前符号在内存中的值,由于链接时还未解析,因此显示为0;加数为一个附加常量偏移。

图23 hello.o的重定位表

 4.3.4 符号表

        如图24所示,符号表包含了17个条目。0~9号条目为局部符号,10~16号条目为全局符号。1号条目为文件类型,名称是hello.i;2~9号条目为节区符号;10号条目为函数类型,名称为main;11~16号条目的符号类型和节索引未定,表示未被解析的外部函数引用。

图24 hello.o的符号表

4.4 Hello.o的结果解析

        如图25为hello.o反汇编结果。机器语言是CPU直接执行的二进制指令,由操作码和操作数构成,汇编语言是对机器语言的一种符号化表示。机器语言和汇编语言间的映射包括操作码和汇编助记符、操作数与寄存器/内存编码的映射。

图25 反汇编结果

        汇编语言与机器语言差异如下:

        1. 汇编语言中部分指令助记符有b、w、l、q变体,而机器语言的反汇编代码没有这些变体。

        2. 汇编语言中数字以十进制形式表示而反汇编代码中以十六进制形式表示。例如,程序进入main函数后,栈顶指针需减少32,这一操作在汇编语言中为subq $32, %rsp而在反汇编代码中为sub $0x20, %rsp,0x20为32的十六进制表示。

        3. 如图26所示,汇编语言中的外部函数调用通过call指令加函数名实现,而反汇编代码中为callq指令加外部函数的地址,由于此时未进行链接,callq指令的操作数为0,其下方为外部函数的重定位条目。

        4. 如图27所示,汇编语言中的跳转操作通过jmp指令加标签实现,是一种抽象的形式,而反汇编代码中的跳转操作为jmp指令加上目标地址相对于起始地址的偏移量,是一种更具体地形式,可被计算机识别并执行。

图26
图27

 4.5 本章小结

        本章首先概述汇编了汇编的概念及作用,然后分析可重定位目标文件hello.o的ELF文件,最后使用objdump工具得到文件的反汇编代码,并分析反汇编代码与汇编代码间的差异,体现了汇编语言是抽象性的语言而机器语言是具体的面向机器的语言。

 第5章 链接

5.1 链接的概念与作用

        链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以用于编译时,即将源代码翻译为机器码时,加载时,即程序被加载器加载到内存并执行时,还可执行于运行时,也就是用应用程序执行。现代系统中,链接是由叫做链接器程序自动执行的。

        链接可以实现分离编译,将大型应用程序分解为小型的易于管理的模块。程序员可以独立地修改和编译其中某些模块并重新链接应用。

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.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o”得到可执行目标文件hello(图28)。

图28 链接结果

5.3 可执行目标文件hello的格式

5.3.1 ELF头

        如图29为hello文件的ELF头,相较于hello.o文件的ELF头,该ELF头中的类型显示为EXEC即可执行文件,且程序入口点地址和程序头起点有了具体的值,同时节的数量与hello.o的不同。        

图29 hello ELF头

5.3.2 节头表 

图30 hello节头表

        对比hello.o文件的节头表,hello节头表(图30)中节区数量增多且没有.rela.text和.rela.eh_frame节,表示hello.o在链接过程中进行了节区合并和新增以及使用了重定位表。 

5.3.3 符号表

图31 hello符号表

5.4 hello的虚拟地址空间

        通过edb的data dump查看hello程序的虚拟地址信息(图32),从图中可以看到0x00401000至0x402000的所有信息,结合hello程序的节头表(图30)可知图中显示了.init(0x401000)、.plt(0x401020)、.plt.sec(0x401090)、.text(0x4010f0)和.fini(0x4011c0)的具体内容,而0x4011e0到0x402000的区域用0填充。其中.init为程序的初始化代码,程序启动时_start会显式跳转至.inti;.plt和.plt.sec为程序的过程链接表,用于hello程序通过动态链接调用外部函数;.text为主程序的代码;.fini为程序结束前的清理代码,用于清理资源。

图32

5.5 链接的重定位过程分析

        如图33所示,hello的反汇编代码中含有.init、.plt和.text等不同的节,而hello.o的反汇编代码中只有.text节;hello的反汇编代码中每条指令都有唯一的地址,而hello.o反汇编代码中每条指令没有具体的地址。

图33 hello和hello.o反汇编代码比较

        如图34所示,hello.o反汇编代码中有重定位条目,而在hello反汇编代码中的对应位置是具有唯一地址的调用指令。例如hello.o反汇编代码的1a处为一个常量数据的重定位条目,重定位类型为R_X86_64_32,即引用该数据在.rodata段的绝对地址,在hello反汇编代码对应位置,该指令被重定位为lea 0xe12(%rip), %rdi,表示将地址0x4011f6 + 0xe12 = 0x402008传入寄存器%rdi作为puts函数的参数,通过gdb查看,该地址的内容为字符串(图35)。 

图34 
图35

5.6 hello的执行流程

        edb查看结果如下: 

图36

5.7 Hello的动态链接分析

        在C语言动态链接函数调用上,GNU编译系统采用了延迟绑定机制,即将过程地址的绑定推迟到第一次调用该过程时。延迟绑定通过两个数据结构间的交互实现:全局偏移量表(GOT)和过程链接表(PLT)。GOT用于存储函数的实际地址,PLT用于存储跳转到GOT的指令。初始时,GOT每个条目指向PLT条目的第二条指令,该指令会引导程序调用动态链接器解析函数地址。当函数第一次被调用时,PLT 会通过链接器解析并将函数的真实地址写入 GOT,之后对该函数的调用将直接通过 GOT 跳转,无需再次解析。如图37所示,通过objdump获取printf函数的GOT表地址0x404020,然后通过gdb工具观察到第一次调用printf函数前后,其对应GOT表内容发生变化(图38)。

图37
图38

5.8 本章小结

        本章首先介绍了链接的基本概念和作用,然后通过分析hello的ELF结构、节头表、符号表,说明了链接过程中的节区合并与重定位。然后使用edb和gdb工具,观察了虚拟地址空间布局及指令重定位情况。最后以printf函数为例说明了动态链接中的延迟绑定机制,通过PLT和GOT实现函数地址的运行时解析。

第6章 hello进程管理

6.1 进程的概念与作用

        进程是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

        进程的作用如下:

        1. 资源隔离与保护

        一个进程拥有独立的虚拟内存空间,防止其他进程非法访问,而一个进程崩溃通常不影响到其他进程。

        2.资源分配单位

        操作系统通过调度算法分配CPU资源给不同进程。

        3.实现并发

        CPU通过快速切换进程模拟并行。

6.2 简述壳Shell-bash的作用与处理流程

        shell是一个交互型应用级程序,代表用户运行其他程序。Shell执行一系列的读/求值步骤,然后终止、读步骤读取来自用户的一个命令行,求值步骤解析命令行,并代表用户执行程序。

        shell的处理流程为:

        1. 从终端读入输入的命令。

        2. 将输入字符串切分获得所有的参数。

        3. 如果命令参数是内置命令则立即执行,否则调用相应的程序为其分配子进   程并运行。

        4.shell应该接受键盘输入信号,并对这些信号进行相应处理。

6.3 Hello的fork进程创建过程

        在终端中输入命令“./hello 2023 ls 2”后,shell对命令行字符串进行解析并存入参数数组中,然后shell判断“./hello”不为内置命令,因此调用fork函数为当前进程创建子进程,最后在子进程中运行hello程序。shell创建子进程时,子进程得到与父进程虚拟地址空间相同的但是独立的一份副本,同时获得与父进程任何打开文件描述符相同的副本,但子进程PID与父进程不同。

6.4 Hello的execve过程

        1. 删除已存在的用户区域

        execve移除当前进程原有的用户空间内容,包括代码段、数据段和堆栈等,使其与父进程彻底独立。

        2. 映射私有区域

        为hello程序重新建立用户空间结构。包括映射代码段、数据段、.bss段以及栈空间。这些区域是私有的,采用写时复制机制,以优化资源使用。

        3. 映射共享区域

若hello程序依赖共享的动态链接库如标准C库libc.so,则这些库会被映射到用户虚拟地址空间中的共享区域,以便多个进程之间共享只读的库代码。

        4. 设置程序计数器

       execve最后会将当前进程的上下文中的程序计数器指向新程序的入口地址,从而开始执行hello程序的第一条指令。

6.5 Hello的进程执行

6.5.1 时间片

        一个进程在执行程序时,程序计数器PC值得序列称为该进程的逻辑流。一个逻辑流得执行在时间上与另一个流重叠称为并发流,多个流并发地执行称为并发。每个进程执行其逻辑流占用的一段时间为一个时间片,而多个进程轮流执行则称为时间分片或多任务。

6.5.2 上下文切换

        上下文是内核重新启动一个被抢占的进程所需的状态,包括通用目的寄存器、浮点寄存器、程序计数器、状态寄存器、用户栈、内核栈等对象的值。当内核选择新进程执行时会使用上下文切换机制实现控制转移。上下文切换包括保存当前进程上下文,恢复被抢占进程上下文和控制传递三个步骤。

6.5.3 用户模式和内核模式

        内核模式又称超级用户模式,当处理器的控制寄存器设置了模式位,当前进程就运行在内核模式里。内核模式中的进程可以执行指令集的任意指令并访问系统的任意内存位置,同时可以改变模式位。当控制寄存器没有设置模式位时,进程运行在用户模式中,不能执行特权指令或改变模式位。进程从用户模式转为内核模式的唯一方法是引发中断、故障和系统调用等异常,这些异常发生时,控制传递给相应的异常处理程序,此时处理器会将用户模式改为内核模式。

6.5.4 调度过程

        在终端中输入命令“./hello 2023 ls 2”后,shell会将命令解析为程序路径及参数,然后通过fork创建一个子进程。子进程调用execve,内核进入内核模式,清空子进程原有地址空间,并加载hello程序的代码和数据,然后设置入口地址以开始执行新程序。

        在hello程序运行过程中,如果用户按下Ctrl+C或Ctrl+Z,终端会向该进程发送信号,系统将进入内核模式处理信号;程序调用sleep系统调用时,也会进入内核模式,由内核进行进程调度与管理。

        hello程序执行完毕后,调用exit系统调用,操作系统进入内核模式,释放相关资源,并向父进程发送SIGCHLD信号通知子进程已终止。

6.6 hello的异常与信号处理

        hello在执行过程中可能发生中断、陷阱和终止异常。

       对于中断异常,程序运行时,用户在键盘上按下Ctrl+C,内核向前台发送SIGINT信号使程序终止(图39);用户按下Ctrl+Z时,内核向程序发送SIGSTP信号,程序挂起(图40),子进程向父进程发送SIGCHLD信号;用户在键盘上随意按键,程序执行不受影响(图41);程序挂起后用户在终端输入ps、jobs、pstree可得到相应信息(图42、43、44);用户输入fg时,内核发送SIGCONT信号,程序所在子进程转为前台进程,程序继续运行(图45);用户输入kill -9 -pid命令时,内核发送SIGKILL信号强制终止程序(图46)。

       对于陷阱异常,hello程序调用sleep函数时发生系统调用,程序将控制传递给陷阱处理程序运行,处理程序完成后将控制返回给系统调用的下一条指令。

       对于终止异常,当hello程序运行时,计算机发生硬件错误,程序将控制传递给终止处理程序,终止处理程序将控制返回给abort例程,该例程会终止hello程序。

图 39 Ctrl+C情况
图 40 Ctrl+Z情况
图 41 用户乱按情况
图 42 ps 命令结果
图 43 pstree 命令结果
图 44 jobs 命令结果
图 45 fg 命令结果
图 46 kill 命令结果

6.7 本章小结

        本章介绍了进程的基本概念及其在资源隔离、资源分配和并发执行中的作用。阐述了shell作为用户交互界面,如何解析命令并创建子进程执行程序。通过“./hello 2023 ls 2”命令为例,分析了fork创建子进程和execve加载新程序的过程。此外,概述了进程执行中的时间片、上下文切换、用户与内核模式切换及调度机制,最后介绍了程序运行过程中常见的异常和信号处理方法。

第7章 hello的存储管理

7.1 hello的存储器地址空间

        逻辑地址是程序在运行时使用的地址,由编译器生成,通常包括段选择符和段内偏移量。例如,在调用printf函数时,汇编程序中会有call printf的指令,其中所用地址就是逻辑地址。

        线性地址是由逻辑地址通过段机制转换得到的。处理器会将段选择符指向的段基址与段内偏移量相加,得到线性地址。

        虚拟地址是线性地址经过分页机制转换后得到的地址,现代操作系统使用分页机制将内存划分为多个固定大小的页,并为每个进程提供独立的地址空间。分页过程将线性地址映射到虚拟地址空间中。

        物理地址是真实内存中的地址,即最终访问的内存单元。虚拟地址需要通过页表映射到对应的物理地址。

7.2 Intel逻辑地址到线性地址的变换-段式管理

        段式管理的基本原理为将一个程序按照自身的逻辑关系划分为若干个段,每个段具有段名,从0编址且长度可以不同。程序经过编译后,每个段被映射到内存中不同的连续的位置,而不同段间允许不相邻(图47)。

图 47 段式管理系统

        分段系统的逻辑地址由段名(段号)和段内地址(段内偏移量)构成,段号的位数决定程序可以分段的数量,段内地址决定每个段的最大长度。将逻辑地址翻译为线性地址须利用段表数据结构,段表包含若干段表项,各表项长度相同,记录了段号、段长和基址(段在内存中起始位置)。

        如图48所示,逻辑地址翻译为线性地址的过程如下:

        1. CPU提供逻辑地址,逻辑地址给出段号S和段内地址W。

        2. 判断段号S是否超出段表最大表项数量M,若超出则发生越界中断;否则按照段表地址=段表起始地址+S×表项大小计算S段段表项的地址。

        3. 检查段内地址W是否大于S段段表项中的段长C,超过则发生越界中断;若未超过则根据线性地址=段基址b+段内地址W访问相应的内存单元。

图 48 段式管理系统地址变换

7.3 Hello的线性地址到物理地址的变换-页式管理

        在分段系统中,逻辑地址由段号和段内地址构成,而在段页式系统中,逻辑地址由段号、页号和页内地址构成(图49)。段号指明数据在程序的哪一段,其位数决定程序可分段的数量,段的长度可以不相同;一个段中又分为多个页,页号指明数据在哪一页中,各个页的长度相同;页内地址给出数据在某一页中的偏移量。 

图 49 段式管理和段页式管理系统地址比较

        如图50所示,段页式系统中线性地址变换为物理地址的过程如下:

        1. CPU给出逻辑地址,由逻辑地址得到段号S、页号P和页内地址W。

        2. 由段号S得到S段段表项地址与分段系统相同。

        3. S段段表项记录了页表长度(页数量)和页表起始地址,将页号P与页表长度进行越界检查,若不发生越界则计算页表项的地址并访问。

        4. 由页表得到数据在内存中的块号,然后根据物理地址=内存块号+页内地址W访问内存。

图 50 段页式管理系统地址变换

7.4 TLB与四级页表支持下的VA到PA的变换

7.4.1 TLB

        翻译后备缓冲器(TLB)是一种小的、虚拟寻址的缓存,位于地址管理单元(MMU)中,其中每一行都保存一个由单个页表条目(PTE)组成的块,用于加速页表的查找过程。

7.4.2 多级页表

        多级页表指利用层次化的页表结构来对页表进行压缩,其原理为第n级页表的第i个PTE指向第n+1级页表的起始地址。由于当第n级页表的某一PTE为空时,相应的下一级页表就不存在,同时只有一级页表需要常驻内存,因此多级页表可显著减少页表的内存占用。

7.4.3 TLB与四级页表支持下地址变换

        如图51为Core i7的地址翻译过程,采用了TLB加速和四级页表结构,其地址变换过程如下:

        1. CPU给出虚拟地址,由虚拟地址得到虚拟页号VPN和虚拟页内地址VPO。

        2. 根据VPN查询TLB,若TLB命中,则取出物理页号PPN与物理页内地址PPO(等于VPO)组成物理地址;若TLB不命中,则查询页表。

        3. 查询四级页表结构时,CR3提供一级页表起始位置与VPN1组成一级页表中PTE1的位置。PTE1存储的值为二级页表的起始位置,与VPN2共同组成二级页表中PTE2的位置。由此类推,最后由四级页表的PTE4给出实际的物理页号PPN与PPO组成物理地址。

图 51 Core i7地址翻译过程

 

7.5 三级Cache支持下的物理内存访问

        cache为一种小而快速的存储设备,作为CPU与内存间的缓冲区域。现代计算机通常采用三级cache结构,从L1 cache到L3 cache,容量逐级递增而速度逐级递减,L1 cache最接近CPU核心,通常分为数据缓存和指令缓存,L3 cache通常为统一缓存,可由多个CPU核心共享。

        在三级cache结构下,CPU首先给出虚拟地址VA,虚拟地址被翻译成物理地址PA;然后,CPU访问L1 cache,若命中,L1 cache将数据返回CPU,若不命中,向L2 cahce发送请求;然后,CPU访问L2 cache,若命中,L2 cache将数据返回CPU同时将数据复制到L1 cache中,若不命中,向L3 cahce发送请求;然后,CPU访问L3 cache,若命中,L3 cache将数据返回CPU同时将数据复制到L1和L2 cache中,若不命中,向内存发送请求,内存将数据返回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 缺页故障与缺页中断处理

        缺页是指虚拟内存系统中DRAM缓存不命中。系统发生缺页异常时,内核调用缺页异常处理程序,该程序会选择一个牺牲页,若该牺牲页已被修改,则内核会将其复制会内存,然后内核会修改该页的页表条目以表示该页已不在内存中。然后,内核将所需要的页从磁盘复制到内存中,并更新其页表条目。最后,缺页异常处理程序返回并重新启动导致缺页的指令。

7.9动态存储分配管理

7.9.1 动态内存管理的基本方法

        1. 显式分配器

        要求应用显式地释放任何已分配的块。例如,c标准库提供一种叫做malloc程序包的显式分配器。c程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。c++中的new和delete操作符与c中的malloc和free相当。

        2. 隐式分配器

       要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集,例如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。

7.9.2 动态内存管理的策略

        1. 带边界标签的隐式空闲链表

        带边界标签的隐式空闲链表使用边界标签来管理内存块,内存块之间没有显式的指针链接。每个内存块包含头部和尾部的边界标签,这些标签存储块的大小和状态。

        2. 显示空间链表

        显式空闲链表使用链表来维护所有空闲块,链表中的每个节点都包含指向下一个和上一个空闲块的指针。这种方式提供了更高效的空闲块管理。

7.10本章小结

        本章介绍了程序运行时的地址转换过程,从逻辑地址到物理地址的多级映射机制,包括段式管理、段页式管理、多级页表和TLB的加速作用。并说明了现代CPU中三级Cache的访问流程,以及进程创建fork和程序替换execve时的内存映射变化。最后简要介绍了缺页异常的处理和动态内存分配的基本策略。

 结论

  1. 预处理器读取源代码hello.c,根据预处理指令进行头文件插入、宏展开操作,同时删除源代码中的注释得到hello.i。
  2. 编译器将hello.i编译为hello.s汇编文件,完成高级语言到低级指令的转换,并进行代码优化。
  3. 汇编器将hello.s翻译成机器码,生成可重定位目标文件hello.o,包含二进制指令和符号表。
  4. 链接器对hello.o中的符号进行解析和重定位,得到能够直接加载到内存中执行的hello程序。
  5. 用户在终端中输入./hello命令,shell对命令进行解析,通过调用fork函数创建子进程,然后调用execve函数加载执行hello程序。
  6. Hello程序运行时,内核调度器采用时间片轮转算法的上下文切换机制调度不同进程,CPU执行取指、译码、执行等流水线操作,逐条执行程序指令直至程序的主函数结束。
  7. Hello程序执行完成后,内核发送SIGCHLD信号通知父进程,释放内存、文件描述符等资源,进程终止。

感悟:

        在学习计算机系统之前,我对hello, world程序的理解仅限于在IDE中编写代码,检查无误后直接运行即可。然而通过这门课的学习和大作业的实践,我才意识到,一个简单的程序背后隐藏着编译、链接、加载、执行等一系列复杂的过程。从手动链接生成可执行文件,到分析ELF结构、虚拟地址空间、重定位和动态链接机制,我深刻体会到程序运行背后是整个计算机系统的协同运作。这让我不仅提升了对程序层原理的理解,也增强了问题分析和系统思维能力。

附件

文件名

作用

hello.i

预处理后的文件,头文件和宏已经被展开

hello.s

汇编语言文件,包含描述机器指令的文本代码

hello.o

可重定位目标文件,包含机器语言指令

hello

最终的可执行文件

参考文献

[1] 菜鸟教程. C 预处理器[EB/OL]. [2025.5.14]. https://www.runoob.com/cprogramming/c-preprocessors.html.

[2] PingCode. 编译器在软件开发中的作用是什么[EB/OL]. [2025.5.14]. https://docs.pingcode.com/ask/ask-ask/207501.html.

[3] CSDN. 【程序员的自我修养05】符号修正的功臣——重定位表[EB/OL]. [2025.5.14]. https://blog.csdn.net/xieyihua1994/article/details/134978043.

[4] 博客园. 操作系统——段式存储管理、段页式存储管理[EB/OL]. [2025.5.14]. https://www.cnblogs.com/wkfvawl/p/11733057.html.

 

 

 

 

 

 

 

 

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/Gris_java/article/details/148294281

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--