從C代碼執行過程看編譯器和作業系統協同工作
本節通過一個簡單的C代碼在Linux下執行的過程,介紹編譯器和OS是如何分工、合作完成代碼的執行。
從原始碼到目標代碼
一個簡單的C示例如下:
int global_count = 10;
int add(int i, int j){
return i + j;
}
main(){
int i = 3;
int j = 5;
int result = add(i, j);
}
該示例非常簡單,不存在動態連結,編譯、連結完成後即可在OS中執行。但是程序要在OS上執行,需要符合OS的執行要求,主要包括:
產生的可執行文件必須符合格式規範(如Linux中必須符合ELF格式)。
可執行文件中內容的組織符合OS執行程序的約定規範,例如程序在執行時由數據段(data segment)、代碼段(text segment)等組成。
以Linux系統為例,上面的原始碼和編譯生成的可執行文件(ELF格式)的對應關係如下圖1-3所示。
以Linux/X86-64為例,通過gcc編譯器對上述代碼進行編譯,產生目標文件。文件格式為ELF,可以使用objdump命令(或readelf命令)對編譯後的目標文件進行解析。首先可以確認一下數據段的信息,如下所示:
Disassembly of section .data:
0000000000600868 <__data_start>:
600868: 00 00 add %al,(%rax)
……
000000000060086c <global_count>:
60086c: 0a 00 or (%rax),%al
在這個數據段中有兩個變量__data_start和global_count,其中global_count是代碼中定義的全局變量,可以看到該變量占用的空間為4位元組,初始值為10;而__data_start是gcc在連結時創建的一個全局變量,該變量指向數據段開始的位置,該變量的大小也是4位元組。
編譯器除了滿足OS對於可執行文件的約定規範外,其中一個重要的功能就是針對代碼進行編譯優化(當然也包含了內存數據的布局等)。
接下來看一下代碼段的內容。代碼段非常長,這裡只關注add函數的彙編代碼,如下所示:
220000000000400474 <add>:
400474: 55 push %rbp
400475: 48 89 e5 mov %rsp,%rbp
400478: 89 7d fc mov %edi,-0x4(%rbp)
40047b: 89 75 f8 mov %esi,-0x8(%rbp)
40047e: 8b 45 f8 mov -0x8(%rbp),%eax
400481: 8b 55 fc mov -0x4(%rbp),%edx
400484: 8d 04 02 lea (%rdx,%rax,1),%eax
400487: c9 leaveq
400488: c3 retq
注意:
在gcc編譯過程中採用的是默認編譯優化級別(默認編譯優化級別為O0),如果採用不同的編譯優化級別,生成的代碼會略有不同。
在C/C++中,編譯優化體現在原始碼的編譯時間長短不同,同時不同的編譯代碼執行效率也會不同。在JVM的執行過程中也存在同樣的問題,並且因為JVM在編譯代碼執行過程中需要先等待編譯代碼完成後才能執行,所以編譯時長會直接影響應用執行的性能。
作業系統如何執行目標代碼
OS首先讀取ELF文件,按照進程執行時內存的布局把ELF文件的信息加載到內存中。在64位Linux環境下,文件到內存的映射以及加載後內存的布局如圖1-4所示。
代碼的入口地址位於0x00400000處(32位系統位於0x08048000),本程序真正執行的地址開始於0x00400390(可以從objdump中看到該信息,此處對0進行了省略)。
architecture: i386:X86-64, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0000000000400390
該地址對應的代碼可以在代碼段中找到。彙編代碼如下:
0000000000400390 <_start>:
400390: 31 ed xor %ebp,%ebp
400392: 49 89 d1 mov %rdx,%r9
400395: 5e pop %rsi
400396: 48 89 e2 mov %rsp,%rdx
400399: 48 83 e4 f0 and
$0xfffffffffffffff0,%rsp
40039d: 50 push %rax
40039e: 54 push %rsp
40039f: 49 c7 c0 c0 04 40 00 mov $0x4004c0,%r8
4003a6: 48 c7 c1 d0 04 40 00 mov $0x4004d0,%rcx
4003ad: 48 c7 c7 89 04 40 00 mov $0x400489,%rdi
4003b4: e8 c7 ff ff ff callq 400380
24<__libc_start_main@plt>
4003b9: f4 hlt
該代碼是gcc生成的,它作為入口地址,從此處開始執行。它將通過glibc的庫函數_libc_start_main執行到原始碼中的main函數中(具體細節可以參考其他書籍)。
在上面的代碼示例中,main函數調用了add函數,這裡簡單演示一下從main函數到add函數的執行過程,主要關注棧的變化情況。main函數的彙編代碼如下:
0000000000400489 <main>:
400489: 55 push %rbp
40048a: 48 89 e5 mov %rsp,%rbp
40048d: 48 83 ec 10 sub $0x10,%rsp
400491: c7 45 f4 03 00 00 00 movl $0x3,-0xc(%rbp)
400498: c7 45 f8 05 00 00 00 movl $0x5,-0x8(%rbp)
40049f: 8b 55 f8 mov -0x8(%rbp),%edx
4004a2: 8b 45 f4 mov -0xc(%rbp),%eax
4004a5: 89 d6 mov %edx,%esi
4004a7: 89 c7 mov %eax,%edi
4004a9: e8 c6 ff ff ff callq 400474 <add>
4004ae: 89 45 fc mov %eax,-0x4(%rbp)
4004b1: c9 leaveq
4004b2: c3 retq
從main函數到執行callq指令之前,棧的情況如圖1-5所示。
從圖1-5中可以看到,在調用add之前,main函數需要將參數以及add函數後的下一條指令地址入棧(由於此處add函數需要傳遞的參數比較少,因此直接使用寄存器傳遞。但是需要注意的是main函數中仍然有局部遍歷i和j,它們也在棧中分配),其中傳遞的參數被add函數使用,返回地址用於add函數執行完成後繼續返回main函數執行。當進入add函數中後,棧的情況如圖1-6所示。
棧幀的變化是OS根據晶片的調用約定組織的,不同的晶片有不同的調用約定。在JVM編譯優化中也需要按照調用約定實現相關的代碼。
本文給大家講解的內容是Java虛擬機和垃圾回收基礎知識:Java代碼執行過程介紹, 從C代碼執行過程看編譯器和作業系統協同工作
- 下篇文章給大家講解的內容是Java虛擬機和垃圾回收基礎知識:Java代碼執行過程介紹, 從C++代碼的執行過程看編譯器支持面向對象語言
- 覺得文章不錯的朋友可以轉發此文關注小編;
- 感謝大家的支持