作為這一系列文章中的最后一篇,這篇文章我打算討論的是從編譯到執行的全過程。因為許多地方都是要有了匯編的基礎知識以后才方便討論,所以我把它放到了最后一篇。
匯編語言data segment。編譯并不是對匯編代碼來說的,而是對更高級的語言,如C、C++來說的。如果一個語言最終的編譯結果是可執行文件,那么它一定會先被編譯為匯編語言,然后再被匯編、鏈接為可執行文件。對于C和C++來說,大部分的編譯器都支持輸出匯編結果。比如說對于test.c
, 我們想查看其編譯后的匯編代碼,只需要在命令行中鍵入
clang test.c -S -o test.s
然后就會生成一個包含其匯編代碼的test.s
文件。
arm匯編指令詳解、研究編譯器生成的匯編代碼很有意義。因為現代的編譯器,其都針對不同的平臺、架構有許多優化,這對于我們寫匯編代碼是很有意義的。比如說,對
return 0;
的編譯結果,是
xorl %eax, %eax
retq
事實上,通過異或自身來清零這一操作,在任何架構上都是最高效的。
所謂匯編,就是輸入我們的匯編代碼,輸出目標文件。什么是目標文件呢?假設我們有一個匯編文件test.s
, 然后我們利用
as test.s -o test.o
生成一個test.o
文件。然后,我們在終端下利用file
指令查看其文件類型:
$ file test.o
test.o: Mach-O 64-bit object x86_64
可以看到, 這個文件是object, 也就是目標文件。
那么,目標文件是做什么用的呢?要了解這個,首先我們需要知道「匯編」這一步驟究竟做了什么。
我們知道,匯編語言可以看作機器碼的human-readable版本。因此,從最直觀來看,匯編只需要把匯編代碼翻譯為機器碼就ok了,也就是匯編代碼直接變成可執行文件。這個粗略來看是對的,對于大多數代碼來說,確實直接翻譯為機器碼就好了。但是,如果真的是這樣,隨著人們寫的代碼越來越多,匯編器的有一項工作的負擔就越來越重——翻譯符號。我們之前在匯編語言中大量運用了標簽,一個標簽就對應一個地址。此外,我們也可以引用別的文件、動態鏈接庫的標簽。因此,對于一個標簽,其可能的情況有好多好多種。所以,人們就把這部分功能從匯編器中解放出來,同時,匯編器就變成了對于一個匯編文件,輸出其目標文件。目標文件幾乎包含的就是可執行文件中的機器碼,但是標簽部分卻是空缺的。其會把所有遇到的符號放到一個符號表中,以便查閱。
舉個例子,我們現在有兩個匯編程序test.s
和tmp.s
, 其代碼分別如下:
tmp.s
:
# tmp.s.data.globl tmp_var
tmp_var: .quad 0x114514.text.globl _tmp_func
_tmp_func:retq
test.s
:
# test.s.data
var: .asciz "hello, world!n".text.globl _main
_func:retq_main:pushq %rbpcallq _func # internal callleaq var(%rip), %rdi # internal variablemovb $0, %alcallq _printf # dylib callmovq tmp_var(%rip), %rdi # external variablecallq _tmp_func # external variablepopq %rbpmovq $0, %raxretq
其中主函數位于test.s
. 且test.s
分別包含了對本文件下函數的調用、本文件下變量的訪問、動態鏈接庫中函數的調用、外部文件中函數的調用和外部文件中變量的訪問。
我們在終端中依次鍵入
as test.s -o test.o
as tmp.s -o tmp.o
得到兩個目標文件。我們利用
otool -v -t test.o
可以查看test.o
文件中__TEXT
段__text
節的代碼:
test.o:
(__TEXT,__text) section
_func:
0000000000000000 retq
_main:
0000000000000001 pushq %rbp
0000000000000002 callq 0x7
0000000000000007 leaq (%rip), %rdi
000000000000000e movb $0x0, %al
0000000000000010 callq 0x15
0000000000000015 movq (%rip), %rdi
000000000000001c callq 0x21
0000000000000021 popq %rbp
0000000000000022 movq $0x0, %rax
0000000000000029 retq
同時,我們在終端中鍵入
nm -n -m test.o
可以查看test.o
的符號表:
(undefined) external _printf(undefined) external _tmp_func(undefined) external tmp_var
0000000000000000 (__TEXT,__text) non-external _func
0000000000000001 (__TEXT,__text) external _main
000000000000002a (__DATA,__data) non-external var
可以看到,對于本文件中定義的符號,符號表中已經有了位置,同時依據是否用.globl
聲明區分為external和non-external. 對于未在本文件中定義的符號,都是undefined.
之前我們講到的符號定位的功能,就是鏈接的作用。鏈接器接收多個目標文件,最終輸出為一個可執行文件。對于剛剛我們生成的兩個目標文件test.o
和tmp.o
, 我們在終端中鍵入
ld test.o tmp.o -o test -lSystem
得到可執行文件test
. 我們利用otool
查看其__TEXT
段__text
節的代碼為:
test:
(__TEXT,__text) section
_func:
0000000100000f6b retq
_main:
0000000100000f6c pushq %rbp
0000000100000f6d callq 0x100000f6b
0000000100000f72 leaq 0x1097(%rip), %rdi
0000000100000f79 movb $0x0, %al
0000000100000f7b callq 0x100000f96
0000000100000f80 movq 0x1098(%rip), %rdi
0000000100000f87 callq 0x100000f95
0000000100000f8c popq %rbp
0000000100000f8d movq $0x0, %rax
0000000100000f94 retq
_tmp_func:
0000000100000f95 retq
可以看到,鏈接器將兩個目標文件的段合并了。同一個段同一個節中的代碼被放在了一起。此外,之前標簽處占位的地址,現在也變成了正確的地址。
接著,我們利用nm
查看其符號表:
(undefined) external _printf (from libSystem)(undefined) external dyld_stub_binder (from libSystem)
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100000f6b (__TEXT,__text) non-external _func
0000000100000f6c (__TEXT,__text) external _main
0000000100000f95 (__TEXT,__text) external _tmp_func
0000000100002008 (__DATA,__data) non-external __dyld_private
0000000100002010 (__DATA,__data) non-external var
000000010000201f (__DATA,__data) external tmp_var
其中多出來的dyld_stub_binder
等只是為了動態鏈接,我們暫時不考慮。我們發現,之前處于undefined狀態的_tmp_func
和tmp_var
現在已經被定義了。而且_printf
這樣的動態鏈接庫中的函數,也被確定是from libSystem
了。這就是鏈接器的主要作用。
我剛剛上面多次提到了動態鏈接庫,那么,動態鏈接究竟是什么呢?
首先,我們考慮一個問題。我們知道,有許多庫函數如_printf
等都是十分常用的,所以許多文件在鏈接時都要鏈接包含這些庫函數的文件。那么,如果我們的這些庫函數像上面的匯編過程一樣,包含在某些.o
文件中,比如說lib.o
. 那么,作為鏈接器,ld
會將這些實現_printf
的匯編代碼合并到最終的可執行文件中。當可執行文件執行的時候,又會將這部分代碼放到內存中。那么,假設我們同時運行10個鏈接了lib.o
的可執行文件,那么,內存中同樣的代碼有10份。這顯然是不可以接受的。
此外,還有一個問題。我們知道,系統是不斷升級的。那么,系統提供的庫函數也會隨著時間的變化而不斷升級。如果所有的庫函數都像上面描述的那樣,作為代碼直接寫死到可執行文件里面去,那么,每次升級過后,之前鏈接了這些庫函數的可執行文件,使用的依然是老舊的庫函數。如果要使用新的庫函數,還得重新鏈接。這顯然也是不可以接受的。
為了解決這兩個問題,動態鏈接就應運而生了。與匯編、鏈接不同,動態鏈接是在執行階段的。我們的庫函數,都被放到了一個以.dylib
結尾的動態鏈接庫中。我們在使用ld
鏈接的時候,也可以鏈接動態鏈接庫,如-lSystem
選項實質上就是鏈接了動態鏈接庫libSystem.dylib
. 鏈接器如果遇到動態鏈接庫,那么只會給符號重定位,而不會將代碼整合到可執行文件中。同時,可執行文件中會包含其鏈接的動態鏈接庫。我們也可以利用otool
查看某個可執行文件鏈接的動態鏈接庫,比如說,對于上述的可執行文件test
, 我們在終端下鍵入:
otool -L test
然后就會出現其鏈接的動態鏈接庫(實際上libSystem.dylib
是libSystem.B.dylib
的一個軟鏈接,說不定以后庫文件大規模升級以后,就會軟鏈接到libSystem.C.dylib
):
test:/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1281.0.0)
然后,到程序執行的時候,就是動態鏈接器dyld
發揮的時候了。順便一提,Apple的dyld
是開源的,可以去opensource-apple/dyld上查看。
當程序執行的時候,首先,內核將代碼裝載入其邏輯地址空間,然后,又裝載了動態鏈接器。接著,內核就把控制權轉交給dyld
. 動態鏈接器做的,是找到這個可執行文件鏈接的動態鏈接器,然后把它們裝載入邏輯地址空間。用一個圖表示如下:
注意到,我們提到的是將動態鏈接庫裝載入邏輯地址空間。事實上,在物理內存中,動態鏈接庫只有一份。而內存映射單元MMU將同一個動態鏈接庫的不同邏輯地址映射入同一個物理地址中,這樣就解決了在內存中多個拷貝的問題。
同時,由于是在執行時才裝載,因此,就解決了升級不便的問題。
我在我的GitHub上,知乎專欄上和CSDN上同步更新。
上一篇文章:macOS上的匯編入門(十二)——調試
版权声明:本站所有资料均为网友推荐收集整理而来,仅供学习和研究交流使用。
工作时间:8:00-18:00
客服电话
电子邮件
admin@qq.com
扫码二维码
获取最新动态