摘 要
本文以一个简单的hello.c程序开始,介绍了一个程序在Linux下运行的完整生命周期,包括预处理、编译、汇编、链接、进程管理、存储管理、I/O管理这几部分,一步步详细介绍了程序从被键盘输入、保存到磁盘,直到最后程序运行结束,程序变为僵尸进程的全过程。清晰地观察hello.c的完整周期,生命历程。
关键词:Liunx;P2P;计算机系统;hello
第一章 概述
1.1 Hello简介
P2P:要运行Hello.c,首先要完成由C语言控制台应用到可执行文件的转变,其中要对Hello.c进行四步操作:预处理,编译,汇编,链接,从而生成可执行文件,再在shell中运行该可执行文件,shell为其分配进程空间。这个过程称为P2P。
020:shell使用execve函数运行hello程序,映射虚拟内存,并从程序入口开始载入物理内存,再进入main函数执行目标代码,此时CPU为运行的hello分配时间片执行逻辑控制流,并通过流水线机制运行该程序,在此过程中,计算机通过TLB、4级页表、3级Cache,Pagefile等机制加速hello程序的运行,程序结束后,shell父进程负责回收hello进程,内核删除相关的数据结构。这个过程从hello。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2.6GHz;16G RAM;512GHD Disk
1.2.2 软件环境
Windows10 64位;VirtualBox 11;Ubuntu 20.04 LTS
1.2.3 开发工具
Visual Studio 2021 64位;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
hello.c 源程序
hello.i 预处理后文件
hello.s 编译后的汇编文件
hello.o 汇编后的可重定位目标执行文件
hello 链接之后的可执行目标文件
hello1.elf hello.o的ELF格式
hello2.elf hello的ELF格式
dump_hello.txt hello.o的反汇编
hello_objdump.s hello的反汇编代码
1.4 本章小结
本章主要简单介绍了hello的P2P,020过程,列出了本次实验的环境、中间结果。也列出了该篇论文完成所需要生成的一些中间文件,为后续实验提供了基本思路。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理也称为预编译,它为编译做预备工作,主要进行代码文本的替换工作,用于处理#开头的指令,其中预处理器产生编译器的输出。经过预处理器处理的源程序会有所不同,在预处理阶段所进行的工作只是纯粹的替换和展开,没有任何计算功能。
作用:
1.将源文件中以”include”格式包含的文件复制到编译的源文件中。
2.用实际值替换用“#define”定义的字符串。
3.根据“#if”后面的条件决定需要编译的代码。
2.2 在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
查看hello.i文件,在最开头,是hello.c涉及到的所有头文件的信息。
然后是这些头文件用typedef定义的诸多类型别名。
然后的代码是对很多内部的函数进行声明。
文件的最后,才是真正的hello.c的内容。
2.4 本章小结
本章介绍了预处理的概念和作用,学习了在ubuntu中用cpp指令对hello.c文件进行预处理,将其重定向到hello.i中。我们浏览了hello.i的代码,对hello.i的内容有了感性认识。
第3章 编译
3.1 编译的概念与作用
概念:将高级语言翻译成汇编语言或机器语言的过程。
作用:将高级语言变成汇编语言,并提示语法错误。
3.2 在Ubuntu下编译的命令
【命令】gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1 文件结构分析:
(1)汇编代码:
.file "hello.c"
.text
.section .rodata
.align 8
.LC0:
.string "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201"
.LC1:
.string "Hello %s %s\n"
.text
.globl main
.type main, @function
(2)分析:
内容 | 含义 |
.file | 源文件 |
.text | 代码段 |
.section .rodata | 存放只读变量 |
.align | 对齐方式 |
.string | 表示是string类型 |
.type | 表示是函数类型/对象类型 |
3.3.2 数据:
3.3.2.1常量数据:、
(1)源程序代码:
①if(argc!=4){
②printf("用法: Hello 学号 姓名 秒数!\n");
③exit(1);
④for(i=0;i<8;i++){
⑤printf("Hello %s %s\n",argv[1],argv[2]);
(2)汇编代码:
①cmpl $4, -20(%rbp)
②.LC0:
.string "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201"
③movl $1, %edi
④movl $0, -4(%rbp)
cmpl $7, -4(%rbp)
addl $1, -4(%rbp)
⑤.LC1:
.string "Hello %s %s\n"
3.3.2.2变量数据:
局部变量:
(1)源程序代码:
int i;
for(i=0;i<8;i++){…}
(2)源程序的i存放在-4(%rbp)
汇编代码:
movl $0, -4(%rbp)
cmpl $7, -4(%rbp)
addl $1, -4(%rbp)
3.3.3赋值:
(1)源程序代码:
①exit(1);
②for(i=0;i<8;i++){
(2)将i的初值赋值为0
汇编代码:
①movl $1, %edi
②movl $0, -4(%rbp)
3.3.4类型转换(隐式或显式) :
atoi函数将argv[]中的值转为整型
(1)源程序代码:
sleep(atoi(argv[3]));
(2)汇编代码:
call atoi@PLT
3.3.5算术操作:
(1)源程序代码:
for(i=0;i<8;i++){
(2)汇编代码:
addl $1, -4(%rbp)
3.3.6关系操作:
将i<8,翻译成为cmpl $7 ,-4(%rbp)
将argc!=4变成cmpl $4 , -20(%rbp)
(1)源程序代码:
①if(argc!=4){
②for(i=0;i<8;i++){
(2)汇编代码:
①cmpl $4, -20(%rbp)
je .L2
②cmpl $7, -4(%rbp)
jle .L4
3.3.7数组/指针/结构操作:
(1)源程序代码:
int main(int argc,char *argv[]){
printf("Hello %s %s\n",argv[1],argv[2]);
sleep(atoi(argv[3]));
(2)汇编代码:
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rax
3.3.8控制转移:
(1)源程序代码:
①if(argc!=4){
②for(i=0;i<8;i++){
(2) 如果i<=7,则继续跳转到.L4
如果,argc!=4,则跳转到exit(1)
汇编代码:
①cmpl $4, -20(%rbp)
je .L2
②cmpl $7, -4(%rbp)
jle .L4
3.3.9函数操作:
(1)main()函数:
①参数传递:int argc, char *argv[]。
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
②函数调用:程序开始时调用。
main:
.LFB6:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
③局部变量:int i;
④函数返回:参数个数不正确返回1,正确返回0。
movl $1, %edi
call exit@PLT
movl $0, %eax
leave
(2)printf()函数:
①参数传递:需要输出的字符串。
printf("用法: Hello 学号 姓名 秒数!\n");
leaq .LC0(%rip), %rdi
call puts@PLT
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
leaq .LC1(%rip), %rdi
movl $0, %eax
call printf@PLT
printf("Hello %s %s\n",argv[1],argv[2]);
②函数调用:通过call指令调用。
③局部变量:无
④函数返回:忽略返回值。
(3)exit()函数:
①参数传递:退出状态值。
exit(1);
movl $1, %edi
call exit@PLT
②函数调用:通过call指令调用。
③局部变量:无
④函数返回:函数不返回,直接退出程序。
(4)sleep()函数:
①参数传递:休眠时间。
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rax
movq %rax, %rdi
call atoi@PLT
movl %eax, %edi
call sleep@PLT
sleep(atoi(argv[3]));
②函数调用:通过call指令调用。
③局部变量:无
④函数返回:返回实际休眠时间。
(5)getchar()函数:
①参数传递:无。
②函数调用:通过call指令调用。
③局部变量:无
④函数返回:返回char类型值,被忽略。
3.4 本章小结
简要介绍了编译的概念和作用,然后使用hello程序实际演示了从hello.i到hello.s的过程并结合具体代码对编译结果进行了简单的分析,通过源程序与汇编语言程序的对比,简要说明了编译器是怎么处理C语言的各类操作:数据,赋值,类型转换,算数操作,关系操作,数组,控制转移,函数操作等。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器(as)将.s汇编程序翻译成机器语言,把这些机器语言指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中。这个过程就叫做汇编。
作用:将汇编语言翻译成机器语言,因为机器语言是计算机能直接识别和执行的一种语言。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
命令:readelf -a hello.o > hello1.elf
4.3.1 ELF头
描述了该文件的一些基本信息,类别,大小,格式等等。
4.3.2 节头表
节头表是节头的集合,节头描述了每个节的名字,类型,偏移地址,大小等基本信息。
4.3.3 .rela.text段
可重定位的text段,存放了程序所用到的代码以及一些存放在堆中的数据.rodata,如hello.c中的字符串常量"用法:“Hello 学号 姓名 秒数!\n”。
4.3.4符号表:
.symtab存放在程序中定义和引用的函数和全局变量的信息。
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o > dump_hello.txt
4.4.1分支控制转移不同:
对于跳转语句跳转的位置,hello.s中是.L2、.L3等代码块的名称,而反汇编代码中跳转指令跳转的位置是相对于main函数起始位置偏移的地址(相对地址);
4.4.2函数调用表示不同:
hello.s中,call指令使用的是函数名,而反汇编代码中call指令使用的是待链接器重定位的相对偏移地址,这些调用只有在链接之后才能确定运行时的实际地址,因此在.rela.text节中为其添加了重定位条目,等待静态链接进一步确定;
4.4.3全局变量访问不同:
hello.s中的全局变量、printf字符串等符号被替换成了待重定位的地址,其原因与函数调用类似,.rodata 中数据地址在运行时才能确定;
4.4.4操作数的进制表示不同:
hello.s中的操作数均为十进制,而hello.o反汇编代码中的操作数被转换成十六进制;
4.5 本章小结
本章介绍了hello 从hello.s 到hello.o 的汇编过程,分析了可重定位文件的结构和各个组成部分,以及它们的内容和功能;还查看了hello.o 的elf 格式,并使用objdump 得到反汇编代码与hello.s 进行比较,了解从汇编语言映射到机器语言汇编器需要实现的转换。在这个过程中,生成了重定位条目,为之后的链接中的重定位过程奠定了基础。
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。
作用:链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。
5.2 在Ubuntu下链接的命令
链接的命令:
ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o hello.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o -o hello
5.3 可执行目标文件hello的格式
命令:readelf -a hello > hello2.elf
5.3.1 ELF Header
这是一个64位ELF文件;数据以2的补码、用小端法表示;是可重定位文件;机器类型X86-64;入口点地址是0x1100;程序头开始地址是64;节头部开始地址是14928字节;ELF头的大小是64字节;节头部的大小是64字节;共313个节头,节头部的字符串表索引是30。
5.3.2 Section Header
给出了了各个节的信息,包括名字、类型、大小、对齐方式、读写权限等。
5.3.3 Program Headers
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
5.3.4分段映射节:
5.3.5 动态节:
5.3.6 重定位节:
重定位节记录了各段引用的符号相关信息,在链接时,需要通过重定位节对这些位置的地址进行重定位。链接器会通过重定位条目的类型判断如何计算地址值并使用偏移量等信息计算出正确的地址。本程序8条重定位信息分别是对__libc_start_main、__gmon_start__ 、puts 函数、printf 函数、getchar 函数、atoi函数、exit 函数、sleep 函数进行重定位声明。
5.3.7 符号表部分:
符号表保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
5.4 hello的虚拟地址空间
虚拟地址空间的起始地址为0x400000。
.inerp段的起始地址为04002e0
.text段的起始地址为0x4010f0,
.rodata段的起始地址为0x402000
5.5 链接的重定位过程分析
命令:objdump -d -r hello > hello_objdump.s
链接加入了在hello.c中用到的库函数,如exit、printf、sleep、getchar等函数。
hello中增加了.init和.plt节,和一些节中定义的函数。
Hello实现了调用函数时的重定位,因此在调用函数时调用的地址已经是函数确切的虚拟地址。
Hello实现了调用函数时的重定位,因此在跳转时调用的地址已经是函数确切的虚拟地址。
hello重定位的过程:
(1)重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
(2)重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
(3)重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目,代码的重定位条目放在.rel.txt中。
5.6 hello的执行流程
子程序名:hello!_start 地址:0x00000000004010f0
hello!__libc_csu_init 0x0000000000401270
hello!_init 0x0000000000401000
hello!frame_dummy 0x00000000004011d0
hello!register_tm_clones 0x0000000000401160
hello!main 0x00000000004011d6
hello!printf@plt 0x0000000000401040
hello!atoi@plt 0x0000000000401060
hello!sleep@plt 0x0000000000401080
hello!getchar@plt 0x0000000000401050
hello!exit@plt 0x0000000000401070
hello!__do_global_dtors_aux 0x00000000004011a0
hello!deregister_tm_clones 0x0000000000401130
hello!_fini 0x00000000004012e8
5.7 Hello的动态链接分析
调用dl_init之前.got.plt段的内容:
调用dl_init之后.got.plt段的内容:
延迟绑定通过全局偏移量表(GOT)和过程链接表(PLT)的协同工作实现函数的动态链接,其中GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。在此之后,程序调用共享库函数时,会首先跳转到PLT执行指令,第一次跳转时,GOT条目为PLT下一条指令,将函数ID压栈,然后跳转到PLT[0],在PLT[0]再将重定位表地址压栈,然后转进动态链接器,在动态链接器中使用两个栈条目确定函数运行时地址,重写GOT,再将控制传递给目标函数。以后如果再次调用同一函数,则通过间接跳转将控制直接转移至目标函数。
5.8 本章小结
本章主要介绍了链接的概念及作用,并在linux下实际进行了链接操作,并对生成的hello可执行文件的ELF格式、虚拟地址空间、重定位过程、执行流程、动态链接等方面进行了详细分析。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个正在运行的程序的实例。系统中的每个程序都在某个进程的上下文中运行。上下文由程序正确运行所必需的状态组成。这种状态包括存储在内存中的代码和程序数据、堆栈、通用寄存器的内容、程序计数器、环境变量和文件描述符的集合。
功能:进程为应用程序提供了两种抽象,一种是独立的逻辑控制流,一种是私有地址空间。提高CPU执行效率,减少因程序等待造成的CPU空闲和其他计算机软硬件资源的浪费。
6.2 简述壳Shell-bash的作用与处理流程
Shell是用户级的应用程序,代表用户控制操作系统中的任务。处理流程如下:
① 在shell命令行中输入命令:$./hello
② shell命令行解释器构造argv和envp;
③ 调用fork()函数创建子进程,其地址空间与shell父进程完全相同,包括只读代码段、读写数据段、堆及用户栈等
④ 调用execve()函数在当前进程(新创建的子进程)的上下文中加载并运行hello程序。将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间
⑤ 调用hello程序的main()函数,hello程序开始在一个进程的上下文中运行。
6.3 Hello的fork进程创建过程
过程:
(1)./hello 学号 姓名 秒数
(2)分割命令行作为参数
(3)发现不是内置命令,fork()一个子进程,子进程除PID外完全继承父进程
(4)将参数填入
(5)运行,结束后回到shell
6.4 Hello的execve过程
当调用fork()函数创建了一个子进程之后,子进程调用exceve函数在当前子进程的上下文加载并运行一个新的程序即hello程序,需要以下步骤:
删除已存在的用户区域。删除之前进程在用户部分中已存在的结构。
创建新的代码、数据、堆和栈段。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
设置程序计数器(PC).exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
6.5 Hello的进程执行
6.5.1 逻辑控制流和时间片:
进程的运行本质上是CPU不断从程序计数器 PC 指示的地址处取出指令并执行,值的序列叫做逻辑控制流。操作系统会对进程的运行进行调度,执行进程A->上下文切换->执行进程B->上下文切换->执行进程A->… 如此循环往复。 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。在一个程序被调运行开始到被另一个进程打断,中间的时间就是运行的时间片。
6.5.2 用户模式和内核模式:
用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据。
内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
6.5.3 上下文:
上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。
6.5.4 调度的过程:
在对进程进行调度的过程,操作系统主要做了两件事:加载保存的寄存器,切换虚拟地址空间。
6.5.5 用户态与核心态转换:
为了能让处理器安全运行,需要限制应用程序可执行指令所能访问的地址范围。因此划分了用户态与核心态。
核心态可以说是拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证了系统的安全性。
6.6 hello的异常与信号处理
hello执行中可能出现的异常:
1.中断:异步发生的。在执行hello程序的时候,由处理器外部的I/O设备的信号引起的。I/O设备通过像处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断。这个异常号标识了引起中断的设备。在当前指令完成执行后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。在处理程序返回前,将控制返回给下一条指令。结果就像没有发生过中断一样。
2.陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep函数的时候会出现这个异常。
3.故障:由错误引起,可能被故障处理程序修正。在执行hello时,可能出现缺页故障。
4.终止:不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者 SRAM位被损坏时发生的奇偶错误。
(1)正常运行:
(2)按下ctrl+z
输入ctrl+z挂起前台进程,hello进程没有被回收,使用ps指令查看
(3)按下ctrl+c
输入ctrl+c,内核发送SIGINT信号到前台进程组的每个进程,终止前台作业,用ps查看:
(4)不停乱按
输入都被缓存到stdin,不影响程序输出信息。
(5)jobs命令
(6)pstree命令
(7)kill命令:
6.7本章小结
shell在用户输入命令后,解析命令,如果不是内置命令则执行fork,在子进程中execve加载hello程序,hello至此成为独立的进程,hello运行过程中反复进行了运行态,等待态,就绪态的切换,最终结束后向shell发送SIGCHLD信号结束了自己的一生。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:机器语言中用来指定一个指令或操作数的地址,由段和偏移量组成。
线性地址:是逻辑地址到物理地址的中间层,由段地址加偏移地址组成如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。如果没有启用分页机制,那么线性地址直接就是物理地址。
虚拟地址:与线性地址相似。
物理地址:在计算机科学中,物理地址,也叫实地址、二进制地址,它是在地址总线上,以电子形式存在的,使得数据总线可以访问主存的某个特定存储单元的内存地址。在和虚拟内存的计算机中,物理地址这个术语多用于区分虚拟地址。尤其是在使用内存管理单元(MMU)转换内存地址的计算机中,虚拟和物理地址分别指在经MMU转换之前和之后的地址。在计算机网络中,物理地址有时又是MAC地址的同义词。这个地址实际上是用于数据链路层,而不是如它名字所指的物理层上的。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 Intel 平台下,逻辑地址(logical address)是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address。我们把这个过程称作段式内存管理。
一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址即虚拟地址(VA)到物理地址(PA)之间的转换通过分页机制完成,而分页机制是对虚拟地址内存空间进行分页。\n系统将虚拟页作为进行数据传输的单元。Linux下每个虚拟页大小为4KB。物理内存也被分割为物理页, MMU(内存管理单元)负责地址翻译,MMU使用页表将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。\n\n\nn位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。
7.4 TLB与四级页表支持下的VA到PA的变换
LB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。下图展示了当TLB命中时所包括的步骤,所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。
第1步:CPU产生一个虚拟地址。
第2步和第3步:MMU从TLB中取出相应的PTE。
第4步:MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
第5步:高速缓存/主存将所请求的数据字返回给CPU。当TLB不命中时,MMU必须从Ll缓存中取出相应的PTE。新取出的PTE存放在TLB中,可能会覆盖一个已经存在的条目。
Core i7 MMU如何使用四级的页表来将虚拟地址翻译成物理地址?36位VPN被划分成四个9位的片,每个片被用作到移个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN 1提供到一个Ll PET的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
在三级Cache支持下,CPU访问物理内存首先会去Cache L1中找需要访问的内存是否已被缓存,是否有效,如果已被缓存且有效就称为Cache命中,直接就对其进行读写即可,如果Cache L1没有找到,就去L2中寻找,如果还是没有就去L3中寻找,如果依旧没有,这时才会访问内存,把需要访问的内存附近的整一Cache line都加载进入Cache L3,L2,L1中,然后再进行读写操作。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存,创建当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。
7.7 hello进程execve时的内存映射
execve加载和运行hello程序会经过以下步骤:
①删除已存在的用户区域:这里指在fork后创建于此进程用户区域中的shell父进程用户区域副本。
②映射私有区域:为hello程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射到hello可执行文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
③映射共享区域:hello程序与一些共享对象或目标链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
④设置程序计数器(PC):设置此进程上下文中的程序计数器,使之指向hello代码区域的入口点。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。缺页故障属于异常类别中的故障,是潜在可恢复的错误,主要处理流程可见本文6.6节中关于故障处理的部分。
缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果牺牲页已经被修改了,内核会将其复制回磁盘。随后内核从磁盘复制引发缺页异常的页面至内存,更新对应的页表项指向这个页面,随后返回。
缺页异常处理程序返回后,内核会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件,此次页面会命中。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。分配器将堆视为一组不同大小的块( block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
7.10本章小结
在Linux下系统为了实现对物理内存的屏蔽,引入了虚拟内存的概念,它为进程隐藏了物理内存这一概念,让内存管理变得更加简单。利用局部性原理,实现程序加载的多级缓存,在减少了对内存需要的同时也兼顾了程序的运行效率。无论是TLB还是Cache其策略都是相同的,把经常用的东西放到速度快的地方,而需要写回时都采用了延迟写回的策略,只有在逼不得已的情况下才进行写回,这一点与内存的映射类似,把能一起用的东西放到同一个地方,只有在需要对这个地方需要修改时才进行真正的复制。
结论
hello的一生:
(1)hello.c预处理到hello.i文本文件
(2)hello.i编译到hello.s汇编文件
(3)hello.s汇编到二进制可重定位目标文件hello.o
(4)hello.o链接生成可执行文件hello
(5)bash进程调用fork函数,生成子进程;
(6)execve函数加载运行当前进程的上下文中加载并运行新程序hello
(7)hello的运行需要地址的概念,虚拟地址是计算机系统最伟大的抽象。
(8)hello的输入输出与外界交互,与linux I/O息息相关
(9)hello最终被shell父进程回收,内核会收回为其创建的所有信息
附件
hello.c 源程序
hello.i 预处理后文件
hello.s 编译后的汇编文件
hello.o 汇编后的可重定位目标执行文件
hello 链接之后的可执行目标文件
hello1.elf hello.o的ELF格式
hello2.elf hello的ELF格式
dump_hello.txt hello.o的反汇编
hello_objdump.s hello的反汇编代码
参考文献
[1] Linux内存管理:逻辑地址到线性地址和物理地址的转换.https://blog.csdn.net/pi9nc/article/details/21031651
[2] Shell简介:Bash的功能与解释过程(一)Shell简介.https://zhuanlan.zhihu.com/p/128654625
[3] 从逻辑地址到线性地址. https:/ /blog.csdn.net/weixin_46381158/a rticle/details/118067786