大多數程序員的第一行代碼可能都是從輸出“Hello,World!開始的吧。如果請你寫一個c程序,在屏幕上打印“Hello,World!”,下面的代碼對擁有扎實編程基本功的你而言肯定so easy:
#include <stdio.h>int main()
{printf("Hello, World!\n");return 0;
}
使用gcc編譯運行:
(base) ~/Downloads/exmaples$ gcc main.c -o main
(base) ~/Downloads/exmaples$ ./main
Hello, World!
(base) ~/Downloads/exmaples$
按照一般的編程套路,寫了一個main()函數作為程序的入口函數。這個main()函數是夢(可能是噩夢,前方有難纏的八阿哥)開始的地方嗎?
問題一 這個可執行文件大概依賴于哪些動態庫?
假如你剛開始接觸編程,只是淺淺的知道一個程序的運行需要依賴一些運行環境,但不太清楚究竟依賴哪些環境,這些環境又由哪些部門負責建立?為什么調用了一個printf函數,編譯時沒有鏈接任何庫卻可以編譯通過并正常運行?
這些疑問,千錘百煉的編程大師們都想到了,并提供了一系列的工具來幫助你解開這些疑惑。
一般而言,動態鏈接的可執行程序需要依賴一些動態鏈接庫。這些動態鏈接庫或初始化程序運行的一些基礎環境(比如堆棧),或輔助程序實現特定的功能(比如提供你需要的printf函數)。
對一個可執行程序而言,其運行所需要的庫可以靜態鏈接,也可以動態鏈接。
經常逛盒馬的同學大概都可以看到一些事先為你烹飪好的美味佳肴,這些美味佳肴還有個響亮的名字--預制菜。制作美味佳肴需要的蔥姜蒜、調料等已經和食材本身融為一體。即使是做菜小白,拿到預制菜放進微波爐加熱一下也能無腦輸出一道史詩級別的國民美食。
也有很多民間美食家喜歡自己動手,別人準備好的總歸不一定百分百符合自己的口味。大廚們備菜一般不親自出馬,只負責烹飪的部分。拿到食材烹飪的過程中,需要什么配菜,加什么調料,都由大廚擇機投放并嚴格控制用量,最后也能用腦輸出一頓令人口口相傳的家庭私房菜。
靜態鏈接好比盒馬的預制菜,烹飪所需的食材、配菜及調料作為一個整體被一次性打包。你中有我,我中有你,不能分離。
靜態鏈接的程序在編譯的時候被編譯器將其依賴的模塊和程序本身組裝為一個整體,運行時被整體加載到內存中,如同做菜小白將預制菜放進微波爐加熱一樣。
動態鏈接好比民間美食家的烹飪。美味佳肴的烹飪手法、需要的配菜、調料等已經事先確定。烹飪過程中需要配菜、調料時再擇機加入。食材本身、配菜、調料等是分離的。你是你,我是我。
動態鏈接的可執行程序所依賴的模塊一般只有在真正需要的時候才由動態鏈接器加載至內存運行。
ldd命令可以用來查看一個程序依賴于哪些動態鏈接庫。我們可以使用這個命令來一探究竟:
(base) ~/Downloads/exmaples$ ldd mainlinux-vdso.so.1 (0x00007fffb99f1000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fa470f55000)/lib64/ld-linux-x86-64.so.2 (0x00007fa471168000)
libc.so.6
程序中調用printf向標準輸出輸出“Hello,World!”,printf是c語言標準庫中的函數。
什么是c語言標準庫呢?一個編程語言要建立自己的生態圈就要提高語言的易用性,其中一個很重要的部分,是把常用的功能封裝成函數,以庫的形式提供給用戶使用。不然,用戶無論使用什么功能都需要親自實現一遍,不僅效率低下,而且在重復造輪子。
這時,權威組織(c語言標準委員會,由美國國家標準協會成立)就站出來了。它規定了哪些功能需要封裝成庫函數,并規定了這些庫函數的具體形式。無論什么平臺,什么操作系統,都需要支持這些函數形式。
c語言標準庫可以看做c用戶程序和不同操作系統平臺之間的軟件抽象層,它將不同的操作系統平臺的API抽象成相同的庫函數。這樣,程序在各個平臺和操作系統之間遷移就簡單多了。
gcc編譯的時候默認為程序動態鏈接了libc.so.6,即glibc。glibc是c語言標準庫的一個超集,除了包含c語言標準庫,還有幾個輔助程序運行的運行庫。這些運行庫的功能包括初始化程序運行時環境(如堆的初始化)、調用用戶入口函數等。
/lib64/ld-linux-x86-64.so.2
既然程序運行時需要動態加載glibc,那么肯定需要一個動態鏈接器將動態庫加載到內存并與程序進行鏈接方能運行,/lib64/ld-linux-x86-64.so.2即是動態鏈接器。聰明的你又有疑問了,動態連接器本身又是被誰加載鏈接的呢?陷入了雞生蛋蛋生雞的死循環。
答案是動態鏈接器具有自舉功能。
一個人不借助任何工具,自己把自己提起來,雙腳騰空,這就是自舉。
物理世界的自舉(不借助外力提起自己)是不可能的(如果可以發生,請迅速廣而告之!),但程序的自舉是可以通過精心設計的層次結構實現的:
硬件/固件提供初始執行環境
引導程序通過固定入口點(如bios的0x7c00)獲得控制權
逐級加載,比如引導程序→內核→用戶空間
自引用構建:用簡單版本構建復雜版本(如編譯器自編譯)
這種自舉不是“憑空啟動”,而是通過分層接力,讓系統“拉起自己的鞋帶”。自舉在程序設計中比較常見,比如Go語言最初(Go 1.4及之前版本)的編譯器是用c語言編寫的。從Go 1.5版本開始,Go團隊成功實現了編譯器的自舉。這意味著Go 1.5的編譯器完全由Go語言本身完成。
實現動態鏈接器自舉有什么挑戰呢?
如果我們的程序靜態鏈接了靜態庫a,調用了庫a中的函數func_a,匯編偽代碼可以是這樣:jump func_a。這條指令可以理解為找到func_a在內存中的地址(這個地址存儲了func_a實現的指令序列),跳到這個地址開始執行實現func_a的指令序列。
假設func_a的地址為1000,jump func_a被改寫為jump 1000。
一個函數或變量的地址一開始不確定,某個階段條件成熟后,才能準確確認它們的實際位置,這就叫做符號的重定位。
一個程序的編譯大致可以分為編譯、匯編、鏈接等過程。那么問題來了,編譯器在編譯用戶的程序時,還沒有把a鏈接進來,怎么處理調用func_a的代碼呢,即jump后面的地址該怎么填?
對于靜態鏈接,編譯器的做法是任意填一個地址,鏈接時將用戶程序和庫a打包為一個整體。此時,func_a的地址就可以確定了,再由鏈接器將jump后面的地址改寫為func_a實際的地址。
如果我們的程序動態鏈接了動態庫b,調用了庫b中的函數func_b。做法是否一樣呢?
動態鏈接是被動態鏈接器在程序運行時動態加載到到內存中的,可以借鑒靜態鏈接中更新函數地址的做法,在庫b被加載到內存后由動態鏈接器最終確定func_b的實際位置。
如果這個動態鏈接庫只服務一個進程,這種做法沒問題。如果庫b的函數func_b又調用了動態庫c的函數func_c,且庫b被多個進程同時使用,會有問題嗎?
進程A將庫b加載到自己的地址空間,將func_b中的jump func_c代碼改寫成jump 2000。進程B同樣將庫b加載到自己的地址空間。在進程B的地址空間發生func_b到func_c的調用時,拿到的代碼是jump 2000。2000是進程A地址空間中的地址,該地址在進程B中可能無效,函數調用出錯!
不僅如此,進程竟然可以修改代碼段中的代碼,這是不允許的。
動態鏈接庫的代碼段可以同時被多個進程共享,但數據段會被單獨拷貝一份到各進程的地址空間。數據段是可以被進程修改的,如果它同時被多個進程修改,且這些進程之間無關聯性,數據段中的數據就亂套啦!
一個動態鏈接庫一般包含代碼段和數據段。一段代碼被編譯成動態庫后,代碼段和數據段的相對位置就確定了。
利用動態鏈接庫數據的獨立性、代碼段和數據段的相對位置不變性這兩個特性,可以重新設計動態鏈接庫中符號地址的重定位算法。
庫b的數據段存有一張表格,這個表格的每一項存儲的是需要重定位符號的實際地址,這個實際地址在動態鏈接器將庫加載到進程的地址空間就可以確定下來了。
假如庫b的數據段和jump func_c這條指令的偏移量為500,地址表格的位置在數據段的偏移量為50,func_c是地址表格中的第5項,那么jump func_c可以轉變成jump 500 + 50 + 5 * 8(地址長度在64位機器上是8字節),找到這個地址后,取出該地址的存儲內容就可以獲取func_c在本進程的實際地址了。
jump 500 + 50 + 5 * 8這條指令就是地址無關指令,因為指令跳轉的位置是相對位置。
動態鏈接庫一般被多個進程同時使用,所以往往被編譯成地址無關指令。
動態鏈接器自舉代碼的設計不可以使用任何全局變量,也不可以調用函數,因為沒人幫它填充地址表格中符號的實際地址。
linux-vdso.so.1
用戶空間的程序如果要操控比較底層的功能(比如通過文件系統訪問磁盤)需要經過操作系統這道坎。
蘋果的apple watch一直沒有復制門禁卡的功能是因為沒有nfc硬件嗎?不是,是watch os沒有開放這個功能給應用開發者而已!
我們去圖書館還書,先要去柜臺找管理員,管理員拿到書后再將書放回到原來的位置。為什么管理員有這個特權而我們沒有?
如果把這個特權交給普通讀者呢?張三去還書,李四去還書,王五也去還書。張三素質比較高,知道嚴格按照要求將書放回原來的位置。李四和王五就差點意思,他們還書的位置隨心情而定。久而久之,圖書館里書的放置位置就亂套了。
特權,只能掌握在專業可靠的人手中,為了安全,為了穩定。
為了安全和穩定人民群眾是識大體的,但效率也確實低下。還書要麻煩圖書管理員,圖書館的其它功能難道都需要請出專業的特權人員才能搞定嗎?
有些涉及到系統安全穩定的功能不能妥協可以理解,但有些功能對系統的影響有限,采用一刀切的流程就沒那個必要了。比如圖書館的某本書現在還剩多少本,最早歸還的日期是多少等查詢功能就可以直接向讀者開放,讀者在大廳的自動查詢機上直接查詢即可,沒必要再去勞煩圖書管理員饒一道圈圈。
傳統的系統調用(申請運行內核空間的代碼)需要從用戶態切換到內核態。當發生系統調用時,程序需要進行上下文切換,保存用戶態的寄存器、程序計數器等信息,加載內核態的寄存器、堆棧指針等。從內核態返回時,同樣也會進行寄存器切換等操作。
linux-vdso.so.1是內核鏡像的一部分,內核把一部分功能直接暴露給普通用戶,普通用戶可以直接在用戶空間執行指令,不用陷入內核空間獲取特權后才能運行這段代碼,效率提升明顯。
linux-vdso.so.1在文件系統中沒有與之對應的具體文件,它是內核鏡像的一部分,是內核虛擬出來的一個文件。為了看看這個文件究竟有些什么內容,需要采取一些手段。
首先,我們對main.c的內容做一些改造,讓它在后臺小睡300秒:
#include <unistd.h>int main()
{sleep(300);return 0;
}
編譯,后臺運行:
(base) ~/Downloads/exmaples$ gcc main.c -o main
(base) ~/Downloads/exmaples$ ./main &
[1] 555649
main進程的進程號為555649,看看它的虛擬地址分布:
(base) ~/Downloads/exmaples$ cat /proc/555649/maps
558e701ae000-558e701af000 r--p 00000000 103:02 3324120 /home/solora/Downloads/exmaples/main
558e701af000-558e701b0000 r-xp 00001000 103:02 3324120 /home/solora/Downloads/exmaples/main
558e701b0000-558e701b1000 r--p 00002000 103:02 3324120 /home/solora/Downloads/exmaples/main
558e701b1000-558e701b2000 r--p 00002000 103:02 3324120 /home/solora/Downloads/exmaples/main
558e701b2000-558e701b3000 rw-p 00003000 103:02 3324120 /home/solora/Downloads/exmaples/main
7f740282a000-7f740282c000 rw-p 00000000 00:00 0
7f740282c000-7f740284e000 r--p 00000000 103:02 14944449 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f740284e000-7f74029c6000 r-xp 00022000 103:02 14944449 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f74029c6000-7f7402a14000 r--p 0019a000 103:02 14944449 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f7402a14000-7f7402a18000 r--p 001e7000 103:02 14944449 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f7402a18000-7f7402a1a000 rw-p 001eb000 103:02 14944449 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f7402a1a000-7f7402a1e000 rw-p 00000000 00:00 0
7f7402a38000-7f7402a3a000 rw-p 00000000 00:00 0
7f7402a3a000-7f7402a3b000 r--p 00000000 103:02 14943910 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7402a3b000-7f7402a5e000 r-xp 00001000 103:02 14943910 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7402a5e000-7f7402a66000 r--p 00024000 103:02 14943910 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7402a67000-7f7402a68000 r--p 0002c000 103:02 14943910 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7402a68000-7f7402a69000 rw-p 0002d000 103:02 14943910 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7402a69000-7f7402a6a000 rw-p 00000000 00:00 0
7ffccfc88000-7ffccfcaa000 rw-p 00000000 00:00 0 [stack]
7ffccfcd5000-7ffccfcd9000 r--p 00000000 00:00 0 [vvar]
7ffccfcd9000-7ffccfcdb000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
vdso的地址范圍為7ffccfcd9000-7ffccfcdb000,使用gdb命令將該地址范圍的內容拷貝到一個名為vdso.dso的文件:
(base) ~/Downloads/exmaples$ sudo gdb -p 555649 -batch -ex "dump memory vdso.dso 0x7ffccfcd9000 0x7ffccfcdb000" -ex "detach" -ex "quit"
0x00007f74029091b4 in __GI___clock_nanosleep (clock_id=<optimized out>, clock_id@entry=0, flags=flags@entry=0, req=req@entry=0x7ffccfca7ea0, rem=rem@entry=0x7ffccfca7ea0) at ../sysdeps/unix/sysv/linux/clock_nanosleep.c:78
78 ../sysdeps/unix/sysv/linux/clock_nanosleep.c: No such file or directory.
[Inferior 1 (process 555649) detached]
上面找不到的文件用于調試,不影響我們要討論的內容,可以忽略。走到這里,就可以使用objdump查看linux-vdso.so.1的內容了:
(base) ~/Downloads/exmaples$ objdump -T vdso.dso vdso.dso: file format elf64-x86-64DYNAMIC SYMBOL TABLE:
0000000000000a10 w DF .text 0000000000000413 LINUX_2.6 clock_gettime
0000000000000690 g DF .text 0000000000000348 LINUX_2.6 __vdso_gettimeofday
0000000000000e30 w DF .text 0000000000000060 LINUX_2.6 clock_getres
0000000000000e30 g DF .text 0000000000000060 LINUX_2.6 __vdso_clock_getres
0000000000000690 w DF .text 0000000000000348 LINUX_2.6 gettimeofday
00000000000009e0 g DF .text 0000000000000029 LINUX_2.6 __vdso_time
0000000000000ec0 g DF .text 000000000000009c LINUX_2.6 __vdso_sgx_enter_enclave
00000000000009e0 w DF .text 0000000000000029 LINUX_2.6 time
0000000000000a10 g DF .text 0000000000000413 LINUX_2.6 __vdso_clock_gettime
0000000000000000 g DO *ABS* 0000000000000000 LINUX_2.6 LINUX_2.6
0000000000000e90 g DF .text 0000000000000025 LINUX_2.6 __vdso_getcpu
0000000000000e90 w DF .text 0000000000000025 LINUX_2.6 getcpu
文件包含了五個系統調用及對應的vdso實現,比如__vdso_gettimeofday對應gettimeofday,兩個文件的地址完全一樣。也就是說,內核開放了這五個系統調用的實現,這些實現可以直接在用戶空間執行。
vdso?在技術上是"存在"于大多數進程中,但它不是傳統意義上的庫依賴,而是內核提供的優化機制。對于普通開發者來說,完全不需要關心它的存在。
問題二?不依賴printf打印Hello,World!行不行?
既然printf最終會調用write系統調用,直接使用write不行嗎?
#include <unistd.h>int main()
{const char msg[] = "Hello, World!\n";size_t len = sizeof(msg) - 1;write(0, msg, len);return 0;
}
0表示標準輸出,編譯運行:
(base) ~/Downloads/exmaples$ gcc main.c -o main
(base) ~/Downloads/exmaples$ ./main
Hello, World!
可見,直接調用write也是可行的,既然write能夠實現同樣的功能,c語言標準庫為什么還需要引入printf這個函數呢?
引入printf函數能夠滿足復雜的數據格式化輸出需求,提高代碼的可讀性和可維護性。在實際開發中,可以根據具體的應用場景和需求來選擇使用printf或write。如果只是簡單地輸出數據,write系統調用可能更高效;但如果需要進行復雜的格式化輸出,printf函數則更為合適。
能否既不使用printf也不使用write來實現相同的功能?
系統調用本質是通過cpu的陷阱機制(trap)主動觸發從用戶態到內核態的受控切換。可以直接在c程序中使用內聯匯編指令調用系統調用:
void print()
{const char msg[] = "Hello, World!\n";size_t len = sizeof(msg) - 1;asm volatile ("movq $1, %%rax\n""movq $1, %%rdi\n""movq %0, %%rsi\n""movq %1, %%rdx\n""syscall": : "r"(msg), "r"(len): "rax", "rdi", "rsi", "rdx", "memory");
}
每個系統調用都有對應的編號,write的系統調用號為1,再通過寄存器傳遞write所需的參數,最后調用syscall觸發trap就可以進入到內核態處理write系統調用的流程了。
用同樣的方法可以實現一個類似exit()的函數:
void my_exit()
{asm volatile ("movq $60, %%rax\n""movq $42, %%rdi\n""syscall"::: "rax", "rdi", "rcx", "r11");
}
然后在main函數中調用這兩個函數:
void main() {print();my_exit();
}
編譯運行:
(base) ~/Downloads/exmaples$ gcc main.c -o main
(base) ~/Downloads/exmaples$ ./main
Hello, World!
(base) ~/Downloads/exmaples$ echo $?
42
達到了同樣的目的。
問題三?程序一定要從main()函數開始執行嗎?
將main函數的名字改一下,換成別的名字,比如nomain:
#include <stdio.h>int nomain()
{printf("Hello, World!\n");return 0;
}
編譯:
(base) ~/Downloads/exmaples$ gcc main.c -o main
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o: in function `_start':
(.text+0x24): undefined reference to `main'
collect2: error: ld returned 1 exit status
編譯出錯啦,錯誤提示包含了兩個信息:
- 當運行這個程序的時候,首先調用的是_start,再由_start調用main
- _start是由Scrt1.o提供的函數
使用gcc -v輸出編譯時的更多信息:
(base) ~/Downloads/exmaples$ gcc -v main.c -o nomain/usr/lib/gcc/x86_64-linux-gnu/9/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/9/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/9/lto-wrapper -plugin-opt=-fresolution=/tmp/ccN6Ktn2.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro -o nomain /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/9/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/9 -L/usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/9/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L. -L/usr/lib/gcc/x86_64-linux-gnu/9/../../.. /tmp/cctSwJdZ.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/9/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/crtn.o
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o: in function `_start':
gcc默認的編譯行為鏈接了Scrt1.o這個目標文件(/usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o)
使用nm查看Scrt1.o包含的符號:
(base) ~/Downloads/exmaples$ nm /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o
0000000000000000 D __data_start
0000000000000000 W data_startU _GLOBAL_OFFSET_TABLE_
0000000000000000 R _IO_stdin_usedU __libc_csu_finiU __libc_csu_initU __libc_start_mainU main
0000000000000000 T _start
也確實包含了_start函數。該入口點由內核在加載程序后跳轉執行。_start負責初始化環境,設置棧,處理全局構造(對于 c++ 程序),然后調用main函數。
從git://sourceware.org/git/glibc.git下載glibc源碼,Intel/AMD 64 位平臺上_start的匯編實現在glibc/sysdeps/x86_64/start.S可以找到:
ENTRY (_start)|||call *__libc_start_main@GOTPCREL(%rip)|||
END (_start)
最終調用__libc_start_main,而傳給該函數的參數為(main, argc, argv, init, fini, rtld_fini, stack_end)。至此,熟悉的main函數終于現身了。所有獨立式程序(可執行文件)必須包含全局命名空間的main函數作為入口點,因此只能有一個main符號,不然會給_start造成困擾。
一個可執行程序的入口函數可以由鏈接腳本來控制,鏈接的默認腳本在哪里呢?ld是gcc編譯時使用的鏈接器,可以利用--verbose參數打印鏈接的更多信息:
(base) ~/Downloads/glibc/sysdeps/x86_64$ ld --verbose
GNU ld (GNU Binutils for Ubuntu) 2.34Supported emulations:elf_x86_64elf32_x86_64elf_i386elf_iamcuelf_l1omelf_k1omi386pepi386pe
using internal linker script:
==================================================
/* Script for -z combreloc -z separate-code */
/* Copyright (C) 2014-2020 Free Software Foundation, Inc.Copying and distribution of this script, with or without modification,are permitted in any medium without royalty provided the copyrightnotice and this notice are preserved. */
OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64","elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(_start)------------------
輸出的內容包含默認的鏈接腳本(using internal linker script:),其中ENTRY(_start)指定了程序的入口函數。
由此可見,如果采用gcc的默認編譯行為,程序的入口必然是_start,_start必然會調用main函數,如果不提供main函數,編譯必然報錯。
既然鏈接腳本可以控制程序的入口函數,我們也可以依葫蘆畫瓢,自己寫一個鏈接腳本控制程序的入口點:
ENTRY(nomain)SECTIONS
{. = 0x400000 + SIZEOF_HEADERS;
}
鏈接腳本的內容參考ld默認鏈接腳本的寫法:
(base) ~/Downloads/exmaples$ ld --verbose | grep -A5 "SIZEOF_HEADERS"PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;.interp : { *(.interp) }.note.gnu.build-id : { *(.note.gnu.build-id) }.hash : { *(.hash) }.gnu.hash : { *(.gnu.hash) }.dynsym : { *(.dynsym) }
0x400000是linux x86_64 架構的?ABI(應用程序二進制接口)規范?中定義的標準入口地址。可執行文件默認加載到虛擬地址空間的0x400000處。. = 0x400000 + SIZEOF_HEADERS表示將當前虛擬地址設置成0x400000 + SIZEOF_HEADERS,文件頭與代碼段物理連續,可單次內存映射完成,提高了裝載時頁映射的效率。
編譯運行:
(base) ~/Downloads/exmaples$ gcc -c -fno-builtin -fno-stack-protector nomain.c
(base) ~/Downloads/exmaples$ ld -T nomain.lds -o nomain nomain.o
(base) ~/Downloads/exmaples$ ./nomain
Hello, World!
現在,即使代碼中沒有main函數,程序也能正常運行輸出結果。
問題四 怎樣減小可執行文件的大小?
先看下目前可執行文件nomain的大小(1520字節):
(base) ~/Downloads/exmaples$ ls -l nomain
-rwxrwxr-x 1 solora solora 1520 6月 16 16:52 nomain
nomain中有哪些段呢?
(base) ~/Downloads/exmaples$ objdump -h nomainnomain: file format elf64-x86-64Sections:
Idx Name Size VMA LMA File off Algn0 .text 0000008a 0000000000400158 0000000000400158 00000158 2**0CONTENTS, ALLOC, LOAD, READONLY, CODE1 .eh_frame 00000078 00000000004001e8 00000000004001e8 000001e8 2**3CONTENTS, ALLOC, LOAD, READONLY, DATA2 .note.gnu.property 00000020 0000000000400260 0000000000400260 00000260 2**3CONTENTS, ALLOC, LOAD, READONLY, DATA3 .comment 0000002b 0000000000000000 0000000000000000 00000280 2**0CONTENTS, READONLY
通過鏈接腳本把除了代碼段(.text)以外的三個段全部去掉看看程序運行是否正常:
nomain.lds:ENTRY(nomain)SECTIONS
{. = 0x400000 + SIZEOF_HEADERS;/DISCARD/ : { *(.comment) *(.eh_frame) *(.note.gnu.property) }
}(base) ~/Downloads/exmaples$ ld -T nomain.lds -o nomain nomain.o
(base) ~/Downloads/exmaples$ ./nomain
Hello, World!
(base) ~/Downloads/exmaples$ objdump -h nomainnomain: file format elf64-x86-64Sections:
Idx Name Size VMA LMA File off Algn0 .text 0000008a 00000000004000e8 00000000004000e8 000000e8 2**0CONTENTS, ALLOC, LOAD, READONLY, CODE
現在nomain看起來只剩下.text段且能正常運行,那么現在的大小是多少呢?
(base) ~/Downloads/exmaples$ ls -l nomain
-rwxrwxr-x 1 solora solora 904 6月 16 17:09 nomain
從1520字節降到了904字節。
問題五 nomain還能進一步減小嗎?
nomain真的只剩下.text段了嗎?用另外一個工具readelf確認下:
(base) ~/Downloads/exmaples$ readelf -S nomain
There are 5 section headers, starting at offset 0x248:Section Headers:[Nr] Name Type Address OffsetSize EntSize Flags Link Info Align[ 0] NULL 0000000000000000 000000000000000000000000 0000000000000000 0 0 0[ 1] .text PROGBITS 00000000004000e8 000000e8000000000000008a 0000000000000000 AX 0 0 1[ 2] .symtab SYMTAB 0000000000000000 000001780000000000000090 0000000000000018 3 3 8[ 3] .strtab STRTAB 0000000000000000 00000208000000000000001d 0000000000000000 0 0 1[ 4] .shstrtab STRTAB 0000000000000000 000002250000000000000021 0000000000000000 0 0 1
Key to Flags:W (write), A (alloc), X (execute), M (merge), S (strings), I (info),L (link order), O (extra OS processing required), G (group), T (TLS),C (compressed), x (unknown), o (OS specific), E (exclude),l (large), p (processor specific)
竟然還有三個頑固分子:.shstrtab、.symtab以及.strtab。它們分別是段名字符串表、符號表和字符串表。
什么是符號表呢?一個變量或函數總得有類型、名稱、作用域等信息吧。符號名稱給我們調試程序帶來很大的便利,應該沒人想對著一串數字來調試程序吧?這些名稱又由字符串表負責存放。
在默認情況下,ld鏈接器在產生可執行文件時會產生這三個段。對于可執行文件來說,符號表和字符串表是可選的,但是段名字符串表用以保存段名,所以它是必不可少的。
可以使用strip命令去除nomain中的符號表:
(base) ~/Downloads/exmaples$ strip nomain
(base) ~/Downloads/exmaples$ ./nomain
Hello, World!
(base) ~/Downloads/exmaples$ ls -l nomain
-rwxrwxr-x 1 solora solora 584 6月 16 17:25 nomain
nomain仍然能夠正常運行輸出結果,但此時nomain的大小從904字節降到了584字節。
現在nomain還剩下哪些段呢?
(base) ~/Downloads/exmaples$ readelf -S nomain
There are 3 section headers, starting at offset 0x188:Section Headers:[Nr] Name Type Address OffsetSize EntSize Flags Link Info Align[ 0] NULL 0000000000000000 000000000000000000000000 0000000000000000 0 0 0[ 1] .text PROGBITS 00000000004000e8 000000e8000000000000008a 0000000000000000 AX 0 0 1[ 2] .shstrtab STRTAB 0000000000000000 000001720000000000000011 0000000000000000 0 0 1
Key to Flags:W (write), A (alloc), X (execute), M (merge), S (strings), I (info),L (link order), O (extra OS processing required), G (group), T (TLS),C (compressed), x (unknown), o (OS specific), E (exclude),l (large), p (processor specific)
沒錯,只剩下代碼段(.text)和段名字符串表(.shstrtab)了。