当前位置: 首页 > 科技观察

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

时间:2023-03-20 18:18:16 科技观察

前几天,有读者群的朋友问:进程创建后,如何进入我写的main函数?今天这篇文章就来聊聊这个话题。首先明确本期讨论范围:C/C++语言本文主要讨论操作系统层面创建和初始化进程和线程的行为,而基于解释器和虚拟机的语言如Python和Java,如何进入main函数的执行,这后面的路径比较长(包括解释器和虚拟机内部的执行过程),后面再说。所以这里我们主要关注C/C++等原生语言的main函数是如何进入的。本文将兼顾Linux和Windows两大主要平台上的详细过程。创建流程第一步是创建流程。在Linux上,我们想要启动一个新的进程,一般是通过fork+exec系列函数来实现的。前者将当前进程“fork”成一个孪生子进程,后者负责替换子进程的执行文件来执行子进程。进程的新程序文件。这里的fork和exec系列函数是操作系统提供给应用程序的API函数。它们最终会通过系统调用进入操作系统内核,通过内核中的进程管理机制完成一个进程的创建。操作系统内核将负责进程的创建。主要有以下工作要做:在内核中创建用于描述进程的数据结构。在Linux上,创建新进程的页目录和页表是task_struct,用于构建新进程。内存地址空间在Linux内核中。由于历史原因,Linux内核早期并没有线程的概念。相反,它使用task:task_struct来描述一个程序的执行实例:process。在内核中,一个任务对应一个task_struct,也就是一个进程,内核的调度单位也是一个接一个的task_struct。后来出现了多线程的概念。Linux内核为了支持多线程技术,task_struct实际上代表一个线程,通过将多个task_struct合并成一个group(通过结构体内部的groupid字段)来描述一个进程。因此,Linux上的线程也被称为轻量级进程。系统调用fork的一个重要任务是创建新进程的task_struct结构。创建完成后,进程就有了调度单元。然后你就会开始能够参与调度,并有机会被执行。加载可执行文件并通过fork成功创建进程后,此时的子进程和父进程就相当于一个正在进行有丝分裂的细胞,两个进程“几乎”完全一样。但是,为了让子进程执行新的程序,需要在子进程中使用exec系列函数来替换进程的可执行程序。exec系列函数也是对系统调用的封装。通过调用它们,它们将进入内核sys_execve执行真正的工作。这项工作有很多细节,其中一项重要的工作就是将可执行文件加载到进程空间,并对其进行分析,提取出可执行文件的入口地址。我们用C、C++等高级语言编写的代码,最终都会被编译器编译生成可执行文件。在Linux上,它是ELF格式。在Windows上,它称为PE文件。无论是ELF文件还是PE文件,在各自的文件头中,都记录了可执行文件的指令入口地址,它表示程序应该从哪里开始执行。这个入口指向哪里?它是我们的主要功能吗?这里有个技巧,先解决一个问题:进程创建后,如何到达这个入口地址?无论是在Windows还是Linux上,应用程序线程都会经常在用户空间和内核空间之间来回穿梭,这可能出现在以下几种情况:当系统调用中断异常从内核返回时,线程如何知道它从哪里来,从哪里回到应用程序空间继续执行呢?答案是当进入内核空间时,线程会自动将上下文(其实就是一些寄存器的内容,比如指令寄存器EIP)保存到线程栈中,记录它来自哪里,等到它返回从内核。从把这个信息加载到栈上,回到原来的地方继续执行。前面说过,子进程是通过sys_execve系统调用进入内核的。后面对可执行文件的解析完成后,得到ELF文件的入口地址,修改原来保存在栈上的上下文信息,将EIP指向ELF文件的入口地址。这样,当sys_execve系统调用结束,回到用户空间后,就可以直接到新的程序入口开始执行代码了。因此,一个很重要的特点是:exec系列函数在正常情况下是不会返回的,一旦进入,任务完成后,执行过程就会转向新的可执行文件入口。另外需要说明的是,除了ELF文件,Linux还支持MS-DOS、COFF等其他格式的可执行文件。除了二进制可执行文件外,它还支持shell脚本。在这种情况下,脚本将以解释器程序作为入口,从ELF入口开始到main函数。上面解释了一个新的进程是如何执行到可执行文件的入口地址的。同时我也留下了一个疑问,这个入口地址是什么?它是我们的主要功能吗?下面是一个简单的C程序,运行后输出经典的helloworld:#includeintmain(){printf("hello,world!\n");return0;}用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++运行时库,然后再对runtime函数进行包装,最终到达我们的main函数。下面是我们在Windows上从创建进程到main函数的完整过程(高清大图:https://bbs.pediy.com/upload/attach/201604/501306_qz5f5hi1n3107kt.png):