前幾天,讀者群裡有小夥伴提問:從程式建立後,到底是怎麼進入我寫的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文章