一个程序在计算机中是如何运行的?

一个程序在计算机中是如何运行的?

这是一个困扰了我很久的问题,但是之前都没有想过好好了解,最近在看《深入理解计算机系统》,想将整个流程归纳总结一下,文章结构包括:可执行目标文件的结构——>操作系统如何加载可执行文件——>CPU如何执行可执行文件。

可执行目标文件结构(第七章节)

编译器通过预处理、编译、汇编、链接生成了最后的可执行目标文件,文件结构如下:

ELF头描述了代码段.text、.init、.rodata,数据段.data和.bss的位置,在最初加载可执行文件时,会被率先读取到内核态的临时缓冲区,用于构建虚拟映射VMA,说明整个程序的虚拟内存里,哪里是代码段,哪里是数据段。

.init是在main()函数执行之前需要运行的代码,想知道请自行了解。在经过链接之后,代码段里的符号引用已经被重定位成了在代码段和数据段的地址,翻译成汇编语言,就是我们所看到的:

.section .rodata

.LC0:

.string "Hello, World!"

.text

.globl main

main:

push rbp

mov rbp, rsp

sub rsp, 16

lea rdi, [rip + .LC0] # rdi = "Hello, World!"

call std::operator<< >(std::ostream&, char const*)

mov rax, 0

leave

ret

CPU会不断地重复,取指令,解析指令,运行指令的过程,在第三部分详细介绍,接下来让我们了解,一个可执行目标文件是如何被加载到内存,构建成一个进程的。

操作系统加载可执行文件(第八、九章节)

VMA

《深入理解计算机系统》的P581做了详细介绍,VMA是Linux内核中用来表示某一段虚拟地址范围的结构体,在struct vm_area_struct中定义。它不直接包含数据,不是“装数据的容器”,而是一个描述,告诉操作系统:

// 通过 vm_flags 和 vm_file 推断某一段虚拟地址空间是干什么的(代码、堆、栈、文件映射等)

struct vm_area_struct {

unsigned long vm_start; // 起始虚拟地址

unsigned long vm_end; // 结束虚拟地址

struct file *vm_file; // 映射的文件

unsigned long vm_flags; // 权限标志(如可读、可写、可执行)

struct mm_struct *vm_mm; // 指向该进程的虚拟内存空间

struct vm_operations_struct *vm_ops; // 缺页处理函数,说明页面缺页时该怎么办

...

};

使用malloc()和mmap()的本质就是通过内核机制在当前进程中创建新的VMA,从而在虚拟地址空间中分配出一段内存。

⭐当CPU访问虚拟地址时,发生了什么?

CPU的动作:取PC = 0x400123,解码mov指令,指令操作数中包含0x601050,CPU 使用页表(+TLB)查找这个地址的物理地址,如果找不到页表项 ➜ 触发Page Fault。

Page Fault 发生后:内核根据fault地址去进程的VMA红黑树中查找这个地址0x601050落在哪个VMA里?找到了VMA,调用该VMA绑定的vm_ops->fault()处理函数,创建页表项和映射物理页,恢复用户态程序继续执行。

⭐某一段的VMA一定存在吗?(原来这就是 Segmentation Fault)

并不是所有虚拟地址空间区域一开始就有对应的VMA,如果程序访问了没有对应VMA的虚拟地址,操作系统会报段错误(Segmentation Fault),终止程序。所以程序的.text、.data和.bss在程序加载时就一定会被操作系统构建对应的 VMA,否则程序根本无法正常执行。

VMA怎么管理?

内核为每一个进程维护自己的任务结构,其中一个条目指向mm_struct,mm_struct有两个字段pgd和mmap,其中pgd指向第一级页表的基址,mmap指向一个维护VMA的链表。

shell启动新的用户进程

./hello_world.out argv1 argv2

shell进程会调用parseline函数,以空格分隔解析参数保存在cmdline,第一个参数可能是一个内置的shell命令或者可执行目标文件。解析完命令后,builtin_command函数会检查第一个命令行参数是不是内置的shell命令,如果是,立即执行后返回1,否则返回0。在builtin_command返回0后,shell会创建一个子进程,并且在子进程中执行execve(argv[0], argv, environ)。shell进程使用fork的时候,会创建新进程的PCB,分配新的PID,创建当前进程的mm_struct,但是子进程的mm_struct会深拷贝父进程的虚拟内存描述,其中pgd指向的页表会被设置为只读,采用COW写时复制策略。

execve系统调用陷入内核态

execve是一个系统调用,作用是用一个新的程序替换当前进程的地址空间。execve执行时会触发syscall,使程序从用户态陷入内核态,过程为:

切换特权级,从用户态(Ring 3)到内核态(Ring 0)。

将当前栈指针从用户态的用户栈切换到这个内核栈顶。

CPU保存进程上下文:将PC、寄存器的值以中断帧的形式压栈,并将进程状态、上次执行位置等信息保存在当前进程的PCB中,等系统调用处理完之后再返回到该地址的下一条地址继续执行。

跳转到syscall表中execve对应的内核函数处理。

内核态更改当前进程上下文

在内核态里,execve执行的动作包括:

清理当前进程的VMA链表,也就是mm_struct里的mmap链表中的所有项。

映射私有区域:分析新的ELF头,构建代码段和数据段等的VMA,注意,这时候除了.text的程序入口等必要的程序加载代码之外,其余的段的VMA还没有构建页表项。

映射共享区域:共享库的内存映射区域在堆和栈之间,建立这一块区域的VMA,这里的页表也是懒加载,缺页时使用缺页异常处理函数构建。

设置新入口点:将PC设置为新程序的入口地址(来自 ELF Header),更新栈指针到新的用户栈。

CPU如何执行可执行目标文件(第四章节)

PC指针被设置为了新程序的入口地址,取指单元使用MMU将虚拟地址翻译为物理地址,并从物理地址(DRAM或者SRAM)中取指令,指令传给译码器,译码器解析指令来控制PC和ALU等。同时PC是自增的,除了一些会引起PC指针赋值的指令之外,PC会自动增加4字节/8字节大小,到下一个指令的地址。MMU会根据虚拟地址获取页表项,再获取物理地址,若页表项不存在,则出发缺页异常。

缺页异常处理函数会判断访问是否合法,如果合法,就分配或加载物理页,并建立页表项;否则,终止进程(如抛出段错误 SIGSEGV)。若合法,会判断页面类型(匿名页、共享页、COW页、文件映射等),文件映射页是mmap映射的文件、加载的 ELF段,分配新的物理页(或从文件中加载页到页缓存),更新页表项,将虚拟地址映射到物理页并设置访问权限,必要时刷新TLB。所有的做完后,会返回到报错的那条指令地址,这次就不会报错了。

Out of memory

32位的计算机,虚拟地址只有4GB,如果运行大模型等较大的程序,mmap就无法分配那么大的虚拟内存地址,所以就会触发Out of memory,但是64位的机器不太会发生无法分配的情况,因为能分配的虚拟内存完全可以容纳整个进程,但是由于虚拟内存远远大于物理内存,所以会不断的触发缺页异常,不断的从机械硬盘上换页,这就是程序卡顿的原因。

malloc(待补充)

参考资料

深入理解计算机系统

相关推荐

5 分钟了解资产证券化(ABS)
365bet欧洲版官网

5 分钟了解资产证券化(ABS)

⏱️ 06-27 👁️ 4045
下降头是什么意思,降头真的存在吗?
365bet欧洲版官网

下降头是什么意思,降头真的存在吗?

⏱️ 06-27 👁️ 7203
黄金24小时走势图
365bet欧洲版官网

黄金24小时走势图

⏱️ 06-27 👁️ 557
朋友用英语怎么写
365bet比分网

朋友用英语怎么写

⏱️ 06-27 👁️ 9381
《命运冠位指定》贞德和黑贞谁厉害 黑化对比介绍
365bet官方博客

《命运冠位指定》贞德和黑贞谁厉害 黑化对比介绍

⏱️ 06-27 👁️ 4845
黄金24小时走势图
365bet欧洲版官网

黄金24小时走势图

⏱️ 06-27 👁️ 557