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

前几天,读者群里有小伙伴提问:从进程建立后,究竟是怎么进入我写的main函数的?web

今天这篇文章就来聊聊这个话题。shell

首先先划定一下这个问题的讨论范围:C/C++语言编程

这篇文章主要讨论的是操做系统层面上对于进程、线程的建立初始化等行为,而像Python、Java等基于解释器、虚拟机的语言,如何进入到main函数执行,这背后的路径则更长(包含了解释器和虚拟机内部的执行流程),之后有机会再讨论。因此这里就重点关注C/C++这类native语言的main函数是如何进入的。跨域

本文会兼顾叙述LinuxWindows两个主要平台上的详细流程。微信

建立进程

第一步,建立进程。数据结构

在Linux上,咱们要启动一个新的进程,通常经过fork + exec系列函数来实现,前者将当前进程“分叉”出一个孪生子进程,后者负责替换这个子进程的执行文件,来执行子进程的新程序文件。多线程

这里的forkexec系列函数,是操做系统提供给应用程序的API函数,在其内部最终都会经过系统调用,进入操做系统内核,经过内核中的进程管理机制,来完成一个进程的建立。app

操做系统内核将负责进程的建立,主要有下面几个工做要作:异步

  • 建立内核中用于描述进程的数据结构,在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上,应用线程都会常常在用户空间和内核空间来回穿梭,这可能出如今如下几种状况发生时:

  • 系统调用
  • 中断
  • 异常

从内核返回时,线程是如何知道本身从哪里进来的,该回到应用空间的哪里去继续执行呢?

答案是,在进入内核空间时,线程将自动保存上下文(其实就是一些寄存器的内容,好比指令寄存器EIP)到线程的堆栈上,记录本身从哪里来的,等到从内核返回时,再从堆栈上加载这些信息,回到原来的地方继续执行。

前面提到,子进程是经过sys_execve系统调用进入到内核中的,在后面完成可执行文件的分析后,拿到了ELF文件的入口地址,将会去修改原来保存在堆栈上的上下文信息,将EIP指向ELF文件的入口地址。这样等sys_execve系统调用结束时,返回到用户空间后,就可以直接转到新的程序入口开始执行代码。

因此,一个很是重要的特色是:exec系列函数正常状况下是不会返回的,一旦进入,完成使命后,执行流程就会转向新的可执行文件入口

另外须要提一下的是,在Linux上,除了ELF文件,还支持一些其余格式的可执行文件,如MS-DOS、COFF

除了二进制的可执行文件,还支持shell脚本,这个状况下将会将脚本解释器程序做为入口来启动

从ELF入口到main函数

上面交代了,一个新的进程,是如何执行到可执行文件的入口地址的。

同时也留了一个问题,这个入口地址是什么?是咱们的main函数吗?

这里有一个简单的C程序,运行起来后输出经典的hello world:

#include <stdio.h>
int main() {
    printf("hello, world!\n");
    return 0;
}

经过gcc编译后,生成了一个ELF可执行文件,经过readelf指令,能够实现对ELF文件的分析,这里能够看到ELF文件的入口地址是0x400430:

随后,咱们经过反汇编神器,IDA打开分析这个文件,看一下位于0x400430入口的地方是什么函数?

能够看到,入口地方是一个叫作 _start 的函数,并非咱们的main函数。

在_start的结尾,调用了 __libc_start_main 函数,而这个函数,位于libc.so中。

你可能疑惑,这个函数是哪里冒出来的,咱们的代码中并无用到它呢?

其实,在进入main函数以前,还有一个重要的工做要作,这就是:C/C++运行时库的初始化。上面的 __libc_start_main 就是在完成这一工做。

在经过GCC进行编译时,编译器将自动完成运行时库的连接,将咱们的main函数封装起来,由它来调用。

glibc是开源的,咱们能够在GitHub上找到这个项目的libc-start.c文件,一窥 __libc_start_main 的真面目,咱们的main函数正是被它在调用。

完整流程

到这里,咱们梳理了,从进程建立fork,到经过exec系列函数完成可执行文件的替换,再到执行流程进入到ELF文件的入口,再到咱们的main函数的完整流程。

Windows上的一些区别

下面简单介绍下Windows上这一流程的一些差别。

首先是建立进程的环节,Windows系统将fork+exec两步合并了一步,经过CreateProcess系列函数一步到位,在其参数中指定子进程的可执行文件路径。

不一样于Linux上进程和线程的边界模糊,在Windows操做系统上,内核是有明确的进程和线程概念定义,进程用EPROCESS结构表示,线程用ETHREAD结构表示。

因此在Windows上,进程相关的工做准备就绪后,还须要单首创建一个参与内核调度的执行单元,也就是进程中的第一个线程:主线程。固然,这个工做也封装在了CreateProcess系列函数中了。

新进程的主线程建立完成后,便开始参与系统调度了。主线程从哪里开始执行呢?内核在建立时就明确进行了指定:nt!KiThreadStartup,这是一个内核函数,线程启动后就从这里开始执行。

线程从这里启动后,再经过Windows的异步过程调用APC机制执行提早插入的APC,进而将执行流程引入应用层,去执行Windows进程应用程序的初始化工做,好比一些核心DLL文件的加载(Kernel32.dll、ntdll.dll)等等。

随后,再次经过APC机制,再转向去执行可执行文件的入口点。

这后面和Linux上的机制相似,一样没有直接到main函数,而是须要先进行C/C++运行时库的初始化,这以后通过运行时函数的包装,才最终来到咱们的main函数。

下面是Windows上,从建立进程到咱们的main函数的完整流程(高清大图:https://bbs.pediy.com/upload/attach/201604/501306_qz5f5hi1n3107kt.png):

如今你清楚,从进程启动是怎么一步步到你的main函数的了吗?有疑惑和不解的地方,欢迎留言交流。

往期TOP5文章

我是Redis,MySQL大哥被我害惨了!

CPU明明8个核,网卡为啥拼命折腾一号核?

由于一个跨域请求,我差点丢了饭碗

完了!CPU一味求快出事儿了!

哈希表哪家强?几大编程语言吵起来了!


本文分享自微信公众号 - 编程技术宇宙(xuanyuancoding)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。