从创建进程到进入main函数,发生了什么?

前几天 , 读者群里有小伙伴提问:从进程创建后 , 到底是怎么进入我写的main函数的?
今天这篇文章就来聊聊这个话题 。
首先先划定一下这个问题的讨论范围:C/C++语言
这篇文章主要讨论的是操作系统层面上对于进程、线程的创建初始化等行为 , 而像Python、Java等基于解释器、虚拟机的语言 , 如何进入到main函数执行 , 这背后的路径则更长(包含了解释器和虚拟机内部的执行流程) , 以后有机会再讨论 。 所以这里就重点关注C/C++这类native语言的main函数是如何进入的 。
从创建进程到进入main函数,发生了什么?文章插图
本文会兼顾叙述Linux和Windows两个主要平台上的详细流程 。
创建进程第一步 , 创建进程 。
在Linux上 , 我们要启动一个新的进程 , 一般通过fork + exec系列函数来实现 , 前者将当前进程“分叉”出一个孪生子进程 , 后者负责替换这个子进程的执行文件 , 来执行子进程的新程序文件 。
这里的fork、exec系列函数 , 是操作系统提供给应用程序的API函数 , 在其内部最终都会通过系统调用 , 进入操作系统内核 , 通过内核中的进程管理机制 , 来完成一个进程的创建 。
操作系统内核将负责进程的创建 , 主要有下面几个工作要做:
创建内核中用于描述进程的数据结构 , 在Linux上是task_struct
创建新进程的页目录、页表 , 用于构建新进程的内存地址空间
在Linux内核中 , 由于历史原因 , Linux内核早期并没有线程的概念 , 而是用任务:task_struct来描述一个程序的执行实例:进程 。
在内核中 , 一个任务对应就是一个task_struct , 也就是一个进程 , 内核的调度单元也是一个个的个task_struct 。
后来 , 多线程的概念兴起 , Linux内核为了支持多线程技术 , task_struct实际上表示的变成了一个线程 , 通过将多个task_struct合并为一组(通过该结构内部的组id字段)再来描述一个进程 。 因此 , Linux上的线程 , 也称为轻量级进程 。
系统调用fork的一个重要使命就是要去创建新进程的task_struct结构 , 创建完成后 , 进程就拥有了调度单元 。 随后将开始可以参与调度并有机会获得执行 。
加载可执行文件通过fork成功创建进程后 , 此时的子进程和父进程相当于一个细胞进行了有丝分裂 , 两个进程“几乎”是一模一样的 。
而要想子进程执行新的程序 , 在子进程中还需要用到exec系列函数来实现对进程可执行程序的替换 。
exec系列函数同样是系统调用的封装 , 通过调用它们 , 将进入内核sys_execve来执行真正的工作 。
这个工作细节比较多 , 其中有一个重要的工作就是加载可执行文件到进程空间并对其进行分析 , 提取出可执行文件的入口地址 。
我们使用C、C++等高级语言编写的代码 , 最终通过编译器会编译生成可执行文件 , 在Linux上 , 是ELF格式 , 在Windows上 , 称之为PE文件 。
无论是ELF文件还是PE文件 , 在各自的文件头中 , 都记录了这个可执行文件的指令入口地址 , 它指示了程序该从哪里开始执行 。
这个入口指向哪里 , 是我们的main函数吗?这里卖一个关子 , 先来解决在这之前的一个问题:进程创建后 , 是如何来到这个入口地址的?
不管在Windows还是Linux上 , 应用线程都会经常在用户空间和内核空间来回穿梭 , 这可能出现在以下几种情况发生时:
系统调用
中断
异常
【从创建进程到进入main函数,发生了什么?】从内核返回时 , 线程是如何知道自己从哪里进来的 , 该回到应用空间的哪里去继续执行呢?
答案是 , 在进入内核空间时 , 线程将自动保存上下文(其实就是一些寄存器的内容 , 比如指令寄存器EIP)到线程的堆栈上 , 记录自己从哪里来的 , 等到从内核返回时 , 再从堆栈上加载这些信息 , 回到原来的地方继续执行 。
前面提到 , 子进程是通过sys_execve系统调用进入到内核中的 , 在后面完成可执行文件的分析后 , 拿到了ELF文件的入口地址 , 将会去修改原来保存在堆栈上的上下文信息 , 将EIP指向ELF文件的入口地址 。 这样等sys_execve系统调用结束时 , 返回到用户空间后 , 就能够直接转到新的程序入口开始执行代码 。
所以 , 一个非常重要的特点是:exec系列函数正常情况下是不会返回的 , 一旦进入 , 完成使命后 , 执行流程就会转向新的可执行文件入口 。
另外需要提一下的是 , 在Linux上 , 除了ELF文件 , 还支持一些其他格式的可执行文件 , 如MS-DOS、COFF