Linux中main是如何執行的#
這是一個看似簡單的問題,但是要從Linux底層一點點研究問題比較多。找到了一遍研究這個問題的文章,但可能比較老了,還是在x86機器上進行的測試。
開始##
問題很簡單:linux是怎么執行我的main()函數的?
在這片文檔中,我將使用下面的一個簡單c程序來闡述它是如何工作的。這個c程序的文件叫做"simple.c"
main()
{
return (0);
}
編譯##
gcc -o simple simple.c
生成可執行文件simple.
在可執行文件中有些什么?##
為了看到在可執行文件中有什么,我們使用一個工具"objdump"
objdump -f simple
simple: file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x080482d0
輸出給出了一些關鍵信息。首先,這個文件的格式是"ELF64"。其次是給出了程序執行的開始地址 "0x080482d0"
什么是ELF?##
ELF是執行和鏈接格式(Execurable and Linking Format)的縮略詞。它是UNIX系統的幾種可執行文件格式中的一種。對於我們的這次探討,有關ELF的有意思的地方是它的頭格式。每個ELF可執行文件都有ELF頭,像下面這個樣子:
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf32_Half e_type; /* Object file type */
Elf32_Half e_machine; /* Architecture */
Elf32_Word e_version; /* Object file version */
Elf32_Addr e_entry; /* Entry point virtual address */
Elf32_Off e_phoff; /* Program header table file offset */
Elf32_Off e_shoff; /* Section header table file offset */
Elf32_Word e_flags; /* Processor-specific flags */
Elf32_Half e_ehsize; /* ELF header size in bytes */
Elf32_Half e_phentsize; /* Program header table entry size */
Elf32_Half e_phnum; /* Program header table entry count */
Elf32_Half e_shentsize; /* Section header table entry size */
Elf32_Half e_shnum; /* Section header table entry count */
Elf32_Half e_shstrndx; /* Section header string table index */
} Elf32_Ehdr;
上面的結構中,"e_entry"字段是可執行文件的開始地址。
地址"0x080482d0"上存放的是什么?是程序執行的開始地址么?##
對於這個問題,我們來對"simple"做一下反匯編。有幾種工具可以用來對可執行文件進行反匯編。我在這里使用了objdump:
objdump --disassemble simple
輸出結果有點長,我不會分析objdump的所有輸出。我們的意圖是看一下地址0x080482d0上存放的是什么。下面是輸出:
080482d0 <_start>:
80482d0: 31 ed xor %ebp,%ebp
80482d2: 5e pop %esi
80482d3: 89 e1 mov %esp,%ecx
80482d5: 83 e4 f0 and $0xfffffff0,%esp
80482d8: 50 push %eax
80482d9: 54 push %esp
80482da: 52 push %edx
80482db: 68 20 84 04 08 push $0x8048420
80482e0: 68 74 82 04 08 push $0x8048274
80482e5: 51 push %ecx
80482e6: 56 push %esi
80482e7: 68 d0 83 04 08 push $0x80483d0
80482ec: e8 cb ff ff ff call 80482bc <_init+0x48>
80482f1: f4 hlt
80482f2: 89 f6 mov %esi,%esi
看上去開始地址上存放的是叫做"_start"的啟動例程。它所做的是清空寄存器,向棧中push一些數據並且調用一個函數。
Stack Top -------------------
0x80483d
-------------------
esi
-------------------
ecx
-------------------
0x8048274
-------------------
0x8048420
-------------------
edx
-------------------
esp
-------------------
eax
-------------------
三個問題##
現在,可能你已經想到了,關於這個棧幀我們有一些問題。
- 這些16進制數是什么?
- 地址80482bc上存放的是什么,哪個函數被_start調用了?
- 看起來這些匯編指令並沒有用一些有意義的值來初始化寄存器。那么誰來初始化這些寄存器?
讓我們來一個一個回答這個問題。
Q1>關於16進制數###
如果你仔細研究了用objdump得到的反匯編輸出,你就能很容易回答這個問題。
下面是這個問題的回答:
0x80483d0: 這是main()函數的地址。
0x8048274: _init()函數的地址。
0x8048420: _finit()函數地址。
_init和_finit是GCC提供的initialization/finalization 函數。
現在,我們不要去關心這些東西。基本上所有這些16進制數都是函數指針。
Q2>地址80482bc上存放的是什么?###
讓我們再次在反匯編輸出中尋找地址80482bc。
如果你看到了,匯編代碼如下:
80482bc: ff 25 48 95 04 08 jmp *0x8049548
這里的*0x8049548是一個指針操作。它跳到地址0x8049548存儲的地址值上。
更多關於ELF和動態鏈接####
使用ELF,我們可以編譯出一個可執行文件,它動態鏈接到幾個libraries上。這里的"動態鏈接"意味着實際的鏈接過程發生在運行時。否則我們就得編譯出一個巨大的可執行文件,這個文件包含了它所調用的所有libraries("一個『靜態鏈接的可執行文件』")。如果你執行下面的命令:
ldd simple
libc.so.6 => /lib/i686/libc.so.6 (0x42000000)
/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)
你就能看到simple動態鏈接的所有libraries。所有動態鏈接的數據和函數都有『動態重定向入口(dynamic relocation entry)』。
這個概念粗略的講述如下:
- 在鏈接時我們不會得知一個動態符號的實際地址。只有在運行時我們才能知道這個實際地址。
- 所以對於動態符號,我們為其實際地址預留出了存儲單元。加載器會在運行時用動態符號的實際地址填充存儲單元。
- 我們的應用通過使用一種指針操作來間接得知動態符號的存儲單元。在我們的例子中,在地址80482bc上,有一個簡單的jump指令。jump到的單元由加載器在運行時存儲到地址0x8049548上。
我們通過使用objdump命令可以看到所有的動態鏈接入口:
objdump -R simple
simple: file format elf32-i386
DYNAMIC RELOCATION RECORDS
OFFSET TYPE VALUE
0804954c R_386_GLOB_DAT __gmon_start__
08049540 R_386_JUMP_SLOT __register_frame_info
08049544 R_386_JUMP_SLOT __deregister_frame_info
08049548 R_386_JUMP_SLOT __libc_start_main
這里的地址0x8049548被叫做"JUMP SLOT",非常貼切。根據這個表,實際上我們想調用的是 __libc_start_main。
__libc_start_main是什么?####
我們在玩一個接力游戲,現在球被傳到了libc的手上。__libc_start_main是libc.so.6中的一個函數。如果你在glibc中查找__libc_start_main的源碼,它的原型可能是這樣的:
extern int BP_SYM (__libc_start_main) (int (*main) (int, char **, char **),
int argc,
char *__unbounded *__unbounded ubp_av,
void (*init) (void),
void (*fini) (void),
void (*rtld_fini) (void),
void *__unbounded stack_end)
__attribute__ ((noreturn));
所有匯編指令需要做的就是建立一個參數棧然后調用__libc_start_main。這個函數需要做的是建立/初始化一些數據結構/環境然后調用我們的main()。讓我們看一下關於這個函數原型的棧幀,
Stack Top -------------------
0x80483d0 main
-------------------
esi argc
-------------------
ecx argv
-------------------
0x8048274 _init
-------------------
0x8048420 _fini
-------------------
edx _rtlf_fini
-------------------
esp stack_end
-------------------
eax this is 0
-------------------
根據這個棧幀我們得知,esi,ecx,edx,esp,eax寄存器在函數 __libc_start_main()被執行前需要被填充合適的值。很清楚的是這些寄存器不是被前面我們所展示的啟動匯編指令所填充的。那么,誰填充了這些寄存器呢?現在只留下唯一的一個地方了——內核。現在讓我們回到第三個問題上。
Q3>內核做了些什么?###
當我們通過在shell上輸入一個名字來執行一個程序時,下面是Linux接下來會發生的:
- Shell調用內核的帶argc/argv參數的系統調用"execve"。
- 內核的系統調用句柄開始處理這個系統調用。在內核代碼中,這個句柄為"sys_execve".在x86機器上,用戶模式的應用會通過以下寄存器將所有需要的參數傳遞到內核中。
- ebx:執行程序名字的字符串
- ecx:argv數組指針
- edx:環境變量數組指針
- 通用的execve內核系統調用句柄——也就是do_execve——被調用。它所做的是建立一個數據結構,將所有用戶空間數據拷貝到內核空間,最后調用search_binary_handler()。Linux能夠同時支持多種可執行文件格式,例如a.out和ELF。對於這個功能,存在一個數據結構"struct linux_binfmt",對於每個二進制格式的加載器在這個數據結構都會有一個函數指針。search_binary_handler()會找到一個合適的句柄並且調用它。在我們的例子中,這個合適的句柄是load_elf_binary()。解釋函數的每個細節是非常乏味的工作。所以我在這里就不這么做了。如果你感興趣,閱讀相關的書籍即可。接下來是函數的結尾部分,首先為文件操作建立內核數據結構,來讀入ELF映像。然后它建立另一個內核數據結構,這個數據結構包含:代碼容量,數據段開始處,堆棧段開始處,等等。然后為這個進程分配用戶模式頁,將argv和環境變量拷貝到分配的頁面地址上。最后,argc和argv指針,環境變量數組指針通過create_elf_tables()被push到用戶模式堆棧中,使用start_thread()讓進程開始執行起來。
當執行_start匯編指令時,棧幀會是下面這個樣子。
Stack Top -------------
argc
-------------
argv pointer
-------------
env pointer
-------------
匯編指令通過以下方式從棧中獲取所有信息:
pop %esi <--- get argc
move %esp, %ecx <--- get argv
actually the argv address is the same as the current
stack pointer.
現在所有東西都准備好了,可以開始執行了。
其他的寄存器呢?##
對於esp來說,它被用來當做應用程序的棧底。在彈出所有必要信息之后,_start例程簡單的調整了棧指針(esp)——關閉了esp寄存器4個低地址位,這完全是有道理的,對於我們的main程序,這就是棧底。對於edx,它被rtld_fini使用,這是一種應用析構函數,內核使用下面的宏定義將它設為0:
#define ELF_PLAT_INIT(_r) do { \
_r->ebx = 0; _r->ecx = 0; _r->edx = 0; \
_r->esi = 0; _r->edi = 0; _r->ebp = 0; \
_r->eax = 0; \
} while (0)
0意味着在x86 Linux上我們不會使用這個功能。
關於匯編指令##
這些匯編codes來自哪里?它是GCC codes的一部分。這些code的目標文件通常在/usr/lib/gcc-lib/i386-redhat-linux/XXX 和 /usr/lib下面,XXX是gcc版本號。文件名為crtbegin.o,crtend.o和gcrt1.o。
總結##
我們總結一下整個過程。
- GCC將你的程序同crtbegin.o/crtend.o/gcrt1.o一塊進行編譯。其它默認libraries會被默認動態鏈接。可執行程序的開始地址被設置為_start。
- 內核加載可執行文件,並且建立正文段,數據段,bss段和堆棧段,特別的,內核為參數和環境變量分配頁面,並且將所有必要信息push到堆棧上。
- 控制流程到了_start上面。_start從內核建立的堆棧上獲取所有信息,為__libc_start_main建立參數棧,並且調用__libc_start_main。
- __libc_start_main初始化一些必要的東西,特別是C library(比如malloc)線程環境並且調用我們的main函數。
- 我們的main會以main(argv,argv)來被調用。事實上,這里有意思的一點是main函數的簽名。__libc_start_main認為main的簽名為main(int, char **, char **),如果你感到好奇,嘗試執行下面的程序。
main(int argc, char** argv, char** env)
{
int i = 0;
while(env[i] != 0)
{
printf("%s\n", env[i++]);
}
return(0);
}
結論##
在Linux中,我們的C main()函數由GCC,libc和Linux二進制加載器的共同協作來執行。
參考##
objdump "man objdump"
ELF header /usr/include/elf.h
__libc_start_main glibc source
./sysdeps/generic/libc-start.c
sys_execve linux kernel source code
arch/i386/kernel/process.c
do_execve linux kernel source code
fs/exec.c
struct linux_binfmt linux kernel source code
include/linux/binfmts.h
load_elf_binary linux kernel source code
fs/binfmt_elf.c
create_elf_tables linux kernel source code
fs/binfmt_elf.c
start_thread linux kernel source code
include/asm/processor.h