前幾天,讀者群里有小伙伴提問:從行程創建后,到底是怎么進入我寫的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函式的完整流程(高清大圖:https://bbs.pediy.com/upload/attach/201604/501306_qz5f5hi1n3107kt.png):

現在你清楚,從行程啟動是怎么一步步到你的main函式的了嗎?有疑惑和不解的地方,歡迎留言交流,
往期TOP5文章
我是Redis,MySQL大哥被我害慘了!
CPU明明8個核,網卡為啥拼命折騰一號核?
因為一個跨域請求,我差點丟了飯碗
完了!CPU一味求快出事兒了!
哈希表哪家強?幾大編程語言吵起來了!
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/196770.html
標籤:python
