從創建進程到進入main函數,發生了什麼?

  • 2020 年 10 月 29 日
  • 筆記

前幾天,讀者群里有小夥伴提問:從進程創建後,到底是怎麼進入我寫的main函數的?

今天這篇文章就來聊聊這個話題。

首先先劃定一下這個問題的討論範圍:C/C++語言

這篇文章主要討論的是作業系統層面上對於進程、執行緒的創建初始化等行為,而像Python、Java等基於解釋器、虛擬機的語言,如何進入到main函數執行,這背後的路徑則更長(包含了解釋器和虛擬機內部的執行流程),以後有機會再討論。所以這裡就重點關注C/C++這類native語言的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上,應用執行緒都會經常在用戶空間和內核空間來回穿梭,這可能出現在以下幾種情況發生時:

  • 系統調用
  • 中斷
  • 異常

從內核返回時,執行緒是如何知道自己從哪裡進來的,該回到應用空間的哪裡去繼續執行呢?

答案是,在進入內核空間時,執行緒將自動保存上下文(其實就是一些暫存器的內容,比如指令暫存器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函數的完整流程(高清大圖://bbs.pediy.com/upload/attach/201604/501306_qz5f5hi1n3107kt.png):

 

 

現在你清楚,從進程啟動是怎麼一步步到你的main函數的了嗎?有疑惑和不解的地方,歡迎留言交流。

往期TOP5文章

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

CPU明明8個核,網卡為啥拚命折騰一號核?

因為一個跨域請求,我差點丟了飯碗

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

哈希表哪家強?幾大程式語言吵起來了!

 

Tags: