相信很多同學都會有疑問,一個程序是如何運行起來的,為什么我們在shell中執行了一個程序,它的main函數就會被調用呢?在main函數被調用之前及之后,又經歷了什么呢?
今天我們就來詳細的說下這個問題。
還是和之前一樣,我畫了一張程序運行的全景圖,在上圖中,一個程序運行所經歷的代碼段,我都標注了其所在的git倉庫、源文件、及函數名,想要自己看源碼的,可以參考下上圖中的這些信息。
我們先從整體上講一下這張圖。
在linux下,我們一般都是通過shell來執行程序的。
shell其實也是一個普通的程序,它也有自己的main函數,它在正常運行后,會通過調用read_command函數,來等待用戶輸入命令。
在接收到用戶輸入的命令后,shell會先使用fork系統調用,創建一個子進程,然后再在這個子進程中,通過execve系統調用,執行最終的用戶程序。
在子進程執行用戶程序期間,shell主進程會調用waitpid函數,阻塞等待子進程的完成,子進程完成之后,waitpid從阻塞狀態中返回,且status參數中會帶著子進程的退出碼,這個退出碼會在后續的邏輯中被保存起來,供用戶查詢。
之后,shell主進程進入到下一次循環,繼續等待用戶輸入命令并執行。
以上就是shell的主體邏輯,對應于上面全景圖中的藍色部分。
下面我們再來看下linux內核中有關execve系統調用的代碼,也就是上面全景圖中的綠色部分。
shell通過execve系統調用,告知linux內核,要在當前進程中執行目標程序,linux內核經過層層代碼,最終到達load_elf_binary函數。
該函數是整個系統調用中最核心的一段邏輯,它主要用來為目標程序準備各種執行環境。
比如,映射代碼區、數據區等到當前進程的虛擬地址空間,將程序名、環境變量、程序參數、及各種其他數據,有規律的壓入到新分配的棧中,等等。
之后,load_elf_binary函數會調用start_thread,進而會調用start_thread_common函數。
在該函數里,會將返回到用戶區之后,要執行的,用戶區程序的起始地址,設置到regs-》ip里,同時也會將上面新初始化好的,用戶堆棧的棧頂地址,設置到regs-》sp里。
當execve系統調用返回到用戶區之后,regs-》ip和regs-》sp里的值,會分別賦值到rip和rsp寄存器里,這樣指定的用戶程序就可以繼續執行了。
這一流程我們在之前的文章 精致全景圖 | 系統調用是如何實現的 中講過,這里就不再贅述。
不過這里還是有一點需要注意,就是設置到regs-》ip中的地址,并不是我們自己程序的起始地址,而是動態鏈接器 /lib64/ld-linux-x86-64.so.2 的起始地址。
之所以要設置動態鏈接器的起始地址,是因為我們需要在返回到用戶區之后,讓其可以繼續為我們的程序準備執行環境,比如,幫忙加載程序依賴的各種動態鏈接庫等。
在動態鏈接器為我們的程序準備好執行環境之后,它會從進程堆棧的auxiliary vector區,取出最終用戶程序的真正起始地址,并跳轉到該位置開始執行。
auxiliary vector區存放的用戶程序的起始地址,是上面linux內核初始化堆棧時設置的。
動態鏈接器相關的代碼就是這些,它對應于上面全景圖中紫色的部分。
在跳轉到我們自己程序的起始地址后,首先執行的并不是我們寫的main函數,而是glibc里名為_start的一段匯編代碼。
這段匯編代碼也比較簡單,主要是從堆棧中獲取main函數所需的argc,argv等參數,然后最終調用我們寫的main函數。
當main函數返回之后,glibc里的后續代碼,會將main函數的返回值,當作該進程的退出碼,然后調用exit結束該進程。
這些代碼對應于上面全景圖中的粉色部分。
進程調用exit退出之后,shell主進程也會從waitpid的阻塞狀態中返回,然后繼續進行下一次循環。
以上就是程序完整的啟動和結束流程。
下面我們來看下具體的源碼實現。
注意,為了方便理解,很多代碼我們都做了刪減。
首先是shell部分,shell是一個普通的程序,它也有自己的main函數:
該函數里調用了reader_loop:
reader_loop的主體邏輯是,在while循環里不斷的使用read_command函數讀取用戶輸入的命令,然后使用execute_command執行該命令。
execute_command函數經過層層代碼后,會使用下圖中的fork,創建一個子進程:
然后在該子進程中,使用execve系統調用,告知linux內核,用當前子進程執行新的用戶程序:
在shell主進程中,會調用waitpid函數,阻塞等待子進程的完成:
當子進程退出后,waitpid會從阻塞狀態中返回,并在status里攜帶子進程的退出碼,之后shell主進程又返回上面的read_command函數,繼續等待用戶下一條命令的輸入。
以上就是bash的主體邏輯,對應于上面全景圖中的藍色部分。
下面我們繼續看全景圖中的綠色部分,也就是linux內核中有關execve的代碼。
當shell的子進程執行execve函數時,linux內核中對應的系統調用被觸發:
沿著函數的調用鏈,我們會找到一個名為do_execveat_common的函數,在該函數中,會將目標程序的文件名、環境變量、及各種程序參數等字符串,拷貝到新創建的用戶堆棧區:
此時,新創建的堆棧區里內容,就如上面全景圖中右下角的a1-a9, b1-b8部分構成的二維網格區域里所示的內容。
其中,黃色區域里存放的是程序參數 。/a.out hello world,藍色區域里存放的是環境變量 SHLVL=2, HOME=/, TERM=linux, PWD=/,橘黃色區域里存放的是要執行的程序文件名 。/a.out。
這些內容和我們執行的測試程序,及其所處的環境也正好一樣:
繼續沿著內核函數調用鏈,我們最終會來到load_elf_binary函數,該函數是整個系統調用的核心。
由于linux上執行的程序基本上都是elf格式,所以內核選擇的加載函數是load_elf_binary,看這個函數時,可以參考elf格式的man文檔:
https://man.archlinux.org/man/elf.5
該函數比較復雜,我對其做了大量刪減,并添加了很多注釋:
該函數最后會調用start_thread函數,進而會調用start_thread_common函數:
這個函數重點需要注意的是對regs-》ip和regs-》sp的賦值,其作用在load_elf_binary函數的截圖中已經注釋過了,就是在返回到用戶區之后,這兩個字段的值會被分別拷貝到rip和rsp寄存器里,所以這里的賦值,就相當于在返回用戶區之后,對rip和rsp寄存器的賦值,這個在 精致全景圖 | 系統調用是如何實現的 有講。
到這里內核部分的代碼就都已經結束了。
由load_elf_binary函數截圖中可見,regs-》ip中設置的地址是elf_entry,即動態鏈接器的起始地址,而不是我們自己程序的起始地址。
原因是,我們還需要動態鏈接器繼續幫我們準備執行環境,比如幫我們加載程序依賴的動態鏈接庫等。
所以在execve系統調用返回到用戶區之后,代碼流程就進入到了動態鏈接器里的邏輯,即上面全景圖中的紫色區域:
上圖中的_start是動態鏈接器的起始執行地址,這個可以通過下面的方式來確認:
在_start函數中,先將rsp寄存器的值,即上面內核新初始化的堆棧的棧頂地址,賦值到rdi中,然后再使用call指令,調用_dl_start函數。
之所以要賦值到rdi寄存器中,是因為c語言的calling convention約定好的,用此方式來傳遞參數。
再看_dl_start函數:
該函數調用了_dl_start_final,返回一個地址,這個地址就是我們自己程序的起始地址。
再看_dl_start_final:
該函數又調用了_dl_sysdep_start:
在這里,動態鏈接器通過內核初始化的堆棧區中的auxiliary vector,找到最終用戶程序的起始執行地址。
再之后,動態鏈接器的函數調用鏈依次退出,最終返回到上面的_start函數。
_start函數之后會順序執行_dl_start_user,相關代碼也在上面的_start函數的截圖里。
其邏輯是,先將rax中的值,即_dl_start函數返回的最終用戶程序的起始地址,賦值到r12寄存器中,然后再jmp到r12寄存器指向的地址,即開始執行最終的用戶程序邏輯。
至于rax中的值,為什么是_dl_start函數返回的地址,這個其實也是 c calling convention 中的約定,感興趣可以自己查下。
以上就是動態鏈接器的全部邏輯,其對應于全景圖中的紫色部分。
最后,邏輯進入到了全景圖中的粉色部分。
動態鏈接器從內核設置的auxiliary vector中,獲取的用戶程序的起始地址,還并不是我們的main函數,而是glibc中一段名為_start的代碼,這個可以通過下面的方式確認:
該_start代碼段內容如下:
它從堆棧中獲取到argc和argv,然后調用__libc_start_main:
在__libc_start_main里,才真正的調用了我們寫的main函數。
當main函數返回之后,__libc_start_main里用main函數返回的值,作為該進程的退出碼,然后調用exit退出當前進程。
當該進程退出后,shell主進程也從waitpid的阻塞狀態返回,并攜帶用戶程序的退出碼。
在上面全景圖這個示例中,返回碼為99:
之后,shell主進程又進入到下一次循環,繼續等待用戶命令并執行,也就是說,又進入到全景圖中的藍色部分。
至此,在linux上執行程序的流程,就形成了一個完整閉環。
你,學廢了嗎?
責任編輯:haq
-
程序
+關注
關注
117文章
3795瀏覽量
81406 -
Shell
+關注
關注
1文章
366瀏覽量
23444
原文標題:精致全景圖 | 程序是如何運行起來的
文章出處:【微信號:LinuxDev,微信公眾號:Linux閱碼場】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論