只有 170 字節,最小的 64 位 Hello World 程序這樣寫成
機器之心轉載
作者:CJ Ting
最簡單的 C 語言 Hello World 程序,底層到底發生了什麼?如何編寫出最小的 64 位 Hello World 程序?
Hello World 應該是每一位程序員的啓蒙程序,出自於 Brian Kernighan 和 Dennis Ritchie 的一代經典著作 The C Programming Language。
// hello.c
#include <stdio.h>
int main() {
printf("hello, world\n");
return 0;
}
這段代碼我想大家應該都太熟悉了,熟悉到可以默寫出來。雖然是非常簡單的代碼,但是如果細究起來,裏面卻隱含着很多細節:
-
#include <stdio.h> 和 #include "stdio.h" 有什麼區別?
-
stdio.h 文件在哪裏?裏面是什麼內容?
-
爲什麼入口是 main 函數?可以寫一個程序入口不是 main 嗎?
-
main 的 int 返回值有什麼用?是誰在處理 main 的返回值?
-
printf 是誰實現的?如果不用 printf 可以做到在終端中打印字符嗎?
上面這些問題其實涉及到程序的編譯、鏈接和裝載,日常工作中也許大家並不會在意。
現代 IDE 在方便我們開發的同時,也將很多底層的細節隱藏了起來。往往寫完代碼以後,點擊「構建」就行了,至於構建在發生什麼,具體是怎麼構建的,很多人並不關心,甚至根本不知道從源代碼到可執行程序這中間經歷了什麼。
編譯、鏈接和裝載是一個巨大的話題,不是一篇博客可以覆蓋的。在這篇博客中,我想使用「文件尺寸」作爲線索,來介紹從 C 源代碼到可執行程序這個過程中,所經歷的一系列過程。
Tip: 關於編譯、鏈接和裝載,這裏想推薦一本書《程序員的自我修養》。不得不說,這個名字起得非常不好,很有譁衆取寵的味道,但是書的內容是不錯的,值得一看。
我們先來編譯上面的程序:
$ gcc hello.c -o hello
$ ./hello
hello, world
$ ll hello
-rwxr-xr-x 1 root root 16712 Nov 24 10:45 hello
Tip: 後續所有的討論都是基於 64 位 CentOS7 操作系統。
我們會發現這個簡單的 hello 程序大小爲 16K。在今天看來,16K 真的沒什麼,但是考慮到這個程序所做的事情,它真的需要 16K 嗎?
在 C 誕生的上個世紀 70 年代,PDP-11 的內存爲 144K,如果一個 hello world 就要佔 16K,那顯然是不合理的,一定有辦法可以縮減體積。
Tip:
說起 C 語言,我想順帶提一下 UNIX。沒有 C 就沒有 UNIX 的成功,沒有 UNIX 的成功也就沒有 C 的今天。誕生於上個世紀 70 年代的 UNIX 不得不說是一項了不起的創造。
這裏推薦兩份關於 UNIX 的資料:
The UNIX Time-Sharing System 是 1974 年由 Dennis Ritchie 和 Ken Thompson 聯合發表的介紹 UNIX 的論文。不要被「論文」二字所嚇到,實際上,這篇文章寫得非常通俗易懂,由 UNIX 的作者們向你娓娓道來 UNIX 的核心設計理念。
The UNIX Operating System 是一段視頻,看身着藍色時尚毛衣的 Kernighan 演示 UNIX 的特性,不得不說,Kernighan 簡直太帥了。
接下來我們來玩一個遊戲,目標是:在 CentOS7 64 位操作系統上,編寫一個體積最小的打印 hello world 的可執行程序。
Executable
我們先來看「可執行程序」這個概念。
什麼是可執行程序?按照字面意思來理解,那就是:可以執行的程序。
ELF
上面用 C 編寫的 hello 當然是可執行程序,毫無疑問。
實際上,我們可以說它是真正的「可執行」程序(區別於後文的腳本),或者說「原生」程序。
因爲它裏面包含了可以直接用於 CPU 執行的機器代碼,它的執行無需藉助外部。
hello 的存儲格式叫做 ELF,全稱爲 Executable and Linkable Format,看名稱可以知道,它既可以用於存儲目標文件,又可以用於存儲可執行文件。
ELF 本身並不難理解,/usr/include/elf.h 中含有 ELF 結構的詳細信息。難理解的是由 ELF 所掀開的底層世界,目標文件是什麼?和執行文件有什麼區別?鏈接在幹什麼?目標文件怎樣變成可執行文件等等等等。
Shebang
接下來我們來看另外一種形式的可執行程序——腳本。
$ cat > hello.sh <<EOF
#!/bin/bash
echo "hello, world"
EOF
$ chmod +x hello.sh
$ ./helo.sh
hello, world
按照定義,因爲這個腳本可以直接從命令行執行,所以它是可執行程序。
那麼 hello 和 hello.sh 的區別在哪裏?
可以發現 hello.sh 的第一行比較奇怪,這是一個叫做 Shebang 的東西 #!/bin/bash,這個東西表明當前文件需要 /bin/bash 程序來執行。
所以,hello 和 hello.sh 的區別就在於:一個可以直接執行不依賴於外部程序,而另一個需要依賴外部程序。
我曾經有一個誤解,認爲 Shebang 是 Shell 在處理,當 Shell 執行腳本時,發現第一行是 Shebang,然後調用相應的程序來執行該腳本。
實際上並不是這樣,對 Shebang 的處理是內核在進行。當內核加載一個文件時,會首先讀取文件的前 128 個字節,根據這 128 個字節判斷文件的類型,然後調用相應的加載器來加載。
比如說,內核發現當前是一個 ELF 文件(ELF 文件前四個字節爲固定值,稱爲魔數),那麼就調用 ELF 加載器。
而內核發現當前文件含有 Shebang,那麼就會啓動 Shebang 指定的程序,將當前路徑作爲第一個參數傳入。所以當我們執行 ./hello.sh 時,在內核中會被變爲 /bin/bash ./hello.sh。
這裏其實有一個小問題,如果要腳本可以從命令行直接執行,那麼第一行必須是 Shebang。Shebang 的形式固定爲 #! 開頭,對於使用 # 字符作爲註釋的語言比如 Python, Ruby, Elixir 來說,這自然不是問題。但是對於 # 字符不是註釋字符的語言來說,這一行就是一個非法語句,必然帶來解釋錯誤。
比如 JavaScript,它就不使用 # 作爲註釋,我們來寫一個帶 Shebang 的 JS 腳本看看會怎麼樣。
$ cat <<EOF > test.js
#!/usr/bin/env node
console.log("hello world")
EOF
$ chmod +x test.js
$ ./test.js
hello world
並沒有出錯,所以這裏是怎麼回事?按道理來說第一行是非法的 JS 語句,解釋器應該要報錯纔對。
如果把第一行的 Shebang 拷貝一份到第二行,會發現報了 SyntaxError,這纔是符合預期的。所以必然是 Node 什麼地方對第一行的 Shebang 做了特別處理,否則不可能不報錯。
大家可以在 Node 的代碼裏面找一找,看看在什麼地方 😉
答案是什麼地方都沒有,或者說在最新的 Node 中,已經沒有地方在處理 Shebang 了。
在 Node v11 中,我們可以看到相應的代碼(https://github.com/nodejs/node/blob/v11.15.0/lib/internal/main/check_syntax.js#L50)。
stripShebang 函數很明顯,它的作用在於啓動 JS 解釋器的時候,將第一行的 Shebang 移除掉。
但是在 Node v12 以後,Node 更新了 JS 引擎 V8 到 7.4,V8 在這個版本中實現一個叫做 Hashbang grammar 的功能,也就是說,從此以後,V8 可以處理 Shebang 了,因此 Node 刪除了相關代碼。
因爲 Shebang 是 V8 在處理了,所以我們在瀏覽器中也可以加載帶有 Shebang 的 JS 文件,不會有任何問題~
我們可以得出結論,支持作爲腳本使用的語言,如果不使用 # 作爲註釋字符,那麼必然要特別處理 Shebang,否則使用起來就太不方便了。
/usr/bin/env
上面的 test.js 文件中,不知道大家是否注意到,解釋器路徑寫的是 /usr/bin/env node。
這樣的寫法如果經常寫腳本,應該不陌生,我之前一直這樣用,但是沒有仔細去想過爲什麼。
首先我們來看 /usr/bin/env 這個程序是什麼。
根據 man env 返回的信息:env - run a program in a modified environment.
env 的主要作用是修改程序運行的環境變量,比如說
$ export name=shell
$ node
> process.env.name
'shell'
$ env name=env node
> process.env.name
'env'
通過 env 我們修改了 node 運行時的環境變量。但是這個功能和我們爲什麼要在 Shebang 中使用 env 有什麼關係?
在 Shebang 中使用 env 其實是因爲另外一個原因,那就是 env 會在 PATH 中搜索程序並執行。
當我們執行 env abc 時,env 會在 PATH 中搜索 abc 然後執行,就和 Shell 一樣。
這就解釋了爲什麼我們要在腳本中使用 /usr/bin/env node。對於想要給他人複用的腳本,我們並不清楚他人系統上 node 的路徑在哪裏,但是我們清楚的是,它一定在 PATH 中。
而同時,絕大部分系統上,env 程序的位置是固定的,那就是 /usr/bin/env。所以,通過使用 /usr/bin/env node,我們可以保證不管其他用戶將 node 安裝在何處,這個腳本都可以被執行。
binfmt_misc
前面我們提到過,內核對於文件的加載其實是有一套「多態」機制的,即根據不同的類型來選擇不同的加載器。
那麼這個過程我們可以自己定製嗎?
當然可以,內核中有一個加載器叫做 binfmt_misc,看名字可以知道,這個加載器用於處理各種各樣非標準的其他類型。
通過一套語法,我們可以告知 binfmt_misc 加載規則,實現自定義加載。
比如我們可以通過 binfmt_misc 實現直接運行 Go 文件。
# 運行 Go 文件的指令是 `go run`,不是一個獨立的程序
# 所以,我們先要寫一個腳本包裝一下
$ cat <<EOF > /usr/local/bin/rungo
#!/bin/bash
go run $1
EOF
# 接下來寫入規則告訴 binfmt_misc 使用上面的程序來加載所有
# 以 .go 結尾的文件
$ echo ':golang:E::go::/usr/local/bin/rungo:' > /proc/sys/fs/binfmt_misc/register
# 現在我們就可以直接運行 Go 文件了
$ cat << EOF > test.go
package main
import "fmt"
func main() {
fmt.Println("hello, world")
}
EOF
$ chmod +x test.go
$ ./test.go
hello, world
Tiny Script
根據上面的知識,如果我們想要編寫一個體積最小的打印 hello world 的腳本,我們要在這兩方面着手:
-
解釋器路徑要儘量短;
-
腳本本身用於打印的代碼要儘量短。
解釋器的路徑很好處理,我們可以使用鏈接。
腳本本身的代碼要短,這就很考驗知識了,我一開始想到的是 Ruby,puts "hello, world" 算是非常短的代碼了,沒有一句廢話。但是後來 Google 才發現,還有更短的,那就是 PHP 😉
PHP 中 打印 hello world 的代碼就是 hello, world,對的,你沒看錯,連引號都不用。
所以,最終我們的結果如下:
# 假設 php 在 /usr/local/bin/php
$ cd /
$ ln -s /usr/local/bin/php p
$ cat <<EOF > final.php
#!/p
hello, world
EOF
$ chmod +x final.php
$ ./final.php
hello, world
$ ll final.php
-rwxr-xr-x 1 root root 18 Dec 2 22:32 final.php
在腳本模式下,我們的成績是 18 個字節,使用的解釋器是 PHP。
其實在腳本模式下編寫最小的 hello world 沒有太大意義,因爲我們完全可以自己寫一個輸出 hello world 的程序作爲解釋器,然後腳本里面只要 #!/x 就行了。
Tiny Native
上面的腳本只是拋磚引玉,接下來我們進入正題,怎樣編寫一個體積最小的打印 hello world 的原生可執行程序?
網上有很多關於這個話題的討論,但基本都是針對 x86 的。現如今 64 位機器早就普及了,所以我們這裏針對的是 64 位的 x64。
Tip: 64 位機器可以執行 32 位的程序,比如我們可以使用 gcc -m32 來編譯 32 位程序。但這只是一個後向兼容,並沒有充分利用 64 位機器的能力。
Step0
首先,我們使用上文提到的 hello.c 作爲基準程序。
// hello.c
#include <stdio.h>
int main() {
printf("hello, world\n");
return 0;
}
gcc hello.c -o hello.out 編譯以後,它的大小是 16712 個字節。
Step1: Strip Symbols
第一步,也是最容易想到的一步,剔除符號表。
符號是鏈接器工作的的基本元素,源代碼中的函數、變量等被編譯以後,都變成了符號。
如果經常從事 C 開發,一定遇到過 ld: symbol not found 的錯誤,往往是忘記鏈接了某個庫導致的。
使用 nm 我們可以查看一個二進制程序中含有哪些符號。
Tip:
nm 是「窺探」二進制的一個有力工具。記得之前有一次蘋果調整了 iOS 的審覈策略,不再允許使用了 UIWebView 的 App 提交。我們的 IPA 裏面不知道哪個依賴使用了 UIWebView,導致蘋果一直審覈不過,每次都要二分註釋、打包、提交審覈,然後等待蘋果的自動檢查郵件告知結果,非常痛苦。
後來我想到了一個辦法,就是使用 nm 查看編譯出來的可執行程序,看看裏面是否有 UIWebView 相關的 symbol,這大大簡化了調試流程,很快就定位到問題了。
對 step0 中的 hello.out 程序使用 nm,輸出如下:
可以看到有一個符號叫做 main,這個對應的就是我們的 main 函數。但是很奇怪沒有看到 printf,而是出現了一個叫做 puts@@GLIBC_2.2.5 的符號。
這裏其實是 GCC 做的一個優化,如果沒有使用格式字符串調用 printf,GCC 會將它換成 puts。
這些符號都存儲在了 ELF 中,主要用於鏈接,對於可執行文件來說,符號並沒有什麼太大作用,所以我們首先可以通過剔除符號表來節省空間。
有兩個方法,第一是通過 strip,第二是通過 GCC 參數。
這裏我們使用第二個方法,gcc -s hello.c -o hello.out 得到新的不含符號表的可執行程序,它的大小是 14512 字節。
雖然結果還是很大,但是我們省了 2K 左右,不錯,再接再厲。
Step2: Optimization
第二個比較容易想到的辦法就是優化,開啓優化以後編譯器會生成更加高效的指令,從而減小文件體積。
使用 gcc -O3 編譯我們的程序,然後會發現,結果沒有任何變化😂。
其實也非常合理,因爲這個程序太簡單了,沒什麼好優化的。
看來要再想想別的辦法。
Step3: Remove Startup Files
之前我們提到過一個問題,是誰在調用 main 函數?
實際上我們編寫的程序都會被默認鏈接到 GCC 提供的 C 運行時庫,叫做 crt。
通過 gcc --verbose 我們可以查看編譯鏈接的詳細日誌。
可以發現我們的程序鏈接了 crt1.o, crti.o, crtbegin.o, crtend.o 以及 crtn.o。
其中 crt1.o 裏面提供的 _start 函數是程序事實上的入口,這個函數負責準備 main 函數需要的參數,調用 main 函數以及處理 main 函數的返回值。
上面這些 crt 文件統稱爲 Start Files。所以,現在我們的思路是,可不可以不用這些啓動文件?
_start 函數主要功能有兩個,第一是準備參數,我們的 main 不使用任何參數,所以這一部分可以忽略。
第二是處理返回值,具體的處理方式是使用 main 函數的返回值調用 exit 系統調用進行退出。
所以如果我們不使用啓動文件的話,只需要自己使用系統調用退出即可。
因爲我們現在不使用 _start 了,自然我們的主函數也沒必要一定要叫做 main,這裏我們改個名字突出一下這個事實。
#include <stdio.h>#include <unistd.h>
int
nomain()
{
printf("hello, world\n");
_exit(0);
}
unistd.h 裏面提供系統調用的相關函數,這裏我們使用的是 _exit。爲什麼是 _exit 而不是 exit?可以參考這個回答「What is the difference between using _exit() & exit() in a conventional Linux fork-exec?」。
通過 gcc -e nomain -nostartfiles 編譯我們的程序,其中 -e 指定入口,--nostartfiles 作用很明顯,告訴 GCC 不必鏈接啓動文件了。
我們得到的結果是 13664 個字節,不錯,又向前邁進了一步。
Step4: Remove Standard Library
現在我們已經不使用啓動文件了,但是我們還在使用標準庫,printf 和 _exit 函數都是標準庫提供的。
可不可以不使用標準庫?
當然也可以。
這裏就要說到系統調用,用戶程序和操作系統的交互通過一系列稱爲「系統調用」的過程來完成。
比如 syscall_64 是 64 位 Linux 的系統調用表,裏面列出了 Linux 提供的所有系統調用。
系統調用工作在最底層,通過約定的寄存器傳遞參數,然後使用一條特別的指令,比如 32 位 Linux 是 int 80h,64 位 Linux 是 syscall 進入系統調用,最後通過約定的寄存器獲取結果。
C 標準庫裏面封裝了相關函數幫助我們進行系統調用,一般我們不用關心調用細節。
現在如果我們不想使用標準庫,那麼就需要自己去完成系統調用,在 hello 程序中我們使用了兩個系統調用:
-
write: 向終端打印字符實際上就是向終端對應的文件寫入數據
-
exit: 退出程序
因爲要訪問寄存器,所以必須要使用內聯彙編。
最終代碼如下,在 C 中內聯彙編的語法可以參考這篇文檔(https://www.ibiblio.org/gferg/ldp/GCC-Inline-Assembly-HOWTO.html)。
char *str = "hello, world\n";
void
myprint()
{
asm("movq $1, %%rax \n"
"movq $1, %%rdi \n"
"movq %0, %%rsi \n"
"movq $13, %%rdx \n"
"syscall \n"
: // no output
: "r"(str)
: "rax", "rdi", "rsi", "rdx");
}
void
myexit()
{
asm("movq $60, %rax \n"
"xor %rdi, %rdi \n"
"syscall \n");
}
int
nomain()
{
myprint();
myexit();
}
使用 gcc -nostdlib 編譯我們的程序,結果是 12912 字節。
能去的我們都去掉了,爲什麼還是這麼大???
Step5: Custom Linker Script
我們先來看上一步得到的結果。
$ readelf -S -W step4/hello.out
Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 0] NULL 0000000000000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 0000000000401000 001000 00006e 00 AX 0 0 16
[ 2] .rodata PROGBITS 0000000000402000 002000 00000e 01 AMS 0 0 1
[ 3] .eh_frame_hdr PROGBITS 0000000000402010 002010 000024 00 A 0 0 4
[ 4] .eh_frame PROGBITS 0000000000402038 002038 000054 00 A 0 0 8
[ 5] .data PROGBITS 0000000000404000 003000 000008 00 WA 0 0 8
[ 6] .comment PROGBITS 0000000000000000 003008 000022 01 MS 0 0 1
[ 7] .shstrtab STRTAB 0000000000000000 00302a 000040 00 0 0 1
可以發現 Size 很小但是 Off 的值非常大,也就是說每個 Section 的體積很小,但是偏移量很大。
使用 xxd 查看文件內容,會發現裏面有大量的 0。所以情況現在很明朗,有人在對齊。
這裏其實是默認的 Linker Script 鏈接腳本在做對齊操作。
控制鏈接器行爲的腳本叫做 Linker Script,鏈接器內置了一個默認腳本,正常情況下我們使用默認的就好。
我們先來看看默認的腳本是什麼內容。
$ ld --verbose
GNU ld (GNU Binutils) 2.34
...
. = ALIGN(CONSTANT (MAXPAGESIZE));
...
. = ALIGN(CONSTANT (MAXPAGESIZE));
...
可以看到裏面有使用 ALIGN 來對齊某些 Section,使得他們的地址是 MAXPAGESIZE 的倍數,這裏 MAXPAGESIZE 是 4K。
這就解釋了爲什麼我們的程序那麼大。
所以現在解決方案也就很清晰了,我們不使用默認的鏈接腳本,自行編寫一個。
$ cat > link.lds <<EOF
ENTRY(nomain)
SECTIONS
{
. = 0x8048000 + SIZEOF_HEADERS;
tiny : { *(.text) *(.data) *(.rodata*) }
/DISCARD/ : { *(*) }
}
EOF
使用 gcc -T link.lds 編譯程序以後,我們得到了 584 字節,巨大的進步!🚀
Step6: Assembly
還有什麼辦法能進一步壓縮嗎?
上面我們是在 C 中使用內聯彙編,爲什麼不直接使用匯編,完全拋棄 C?
我們來試試看,其實上面的 C 代碼轉換成彙編非常直接。
section .data
message: db "hello, world", 0xa
section .text
global nomain
nomain:
mov rax, 1
mov rdi, 1
mov rsi, message
mov rdx, 13
syscall
mov rax, 60
xor rdi, rdi
syscall
這裏我們使用 nasm 彙編器,我喜歡它的語法~
nasm -f elf64 彙編我們的程序,然後使用 ld 配合上面的自定義鏈接腳本鏈接以後得到可執行程序。
最後的結果是 440 字節,離終點又進了一步了✌~
Step7: Handmade Binary
還能再進一步嗎?還有什麼是我們沒控制的?
所有的代碼都已經由我們精確掌控了,但是最終的 ELF 文件依舊是由工具生成的。
所以,最後一步,我們來手動生成 ELF 文件,精確地控制可執行文件的每一個字節。
BITS 64
org 0x400000
ehdr: ; Elf64_Ehdr
db 0x7f, "ELF", 2, 1, 1, 0 ; e_ident
times 8 db 0
dw 2 ; e_type
dw 0x3e ; e_machine
dd 1 ; e_version
dq _start ; e_entry
dq phdr - $$ ; e_phoff
dq 0 ; e_shoff
dd 0 ; e_flags
dw ehdrsize ; e_ehsize
dw phdrsize ; e_phentsize
dw 1 ; e_phnum
dw 0 ; e_shentsize
dw 0 ; e_shnum
dw 0 ; e_shstrndx
ehdrsize equ $ - ehdr
phdr: ; Elf64_Phdr
dd 1 ; p_type
dd 5 ; p_flags
dq 0 ; p_offset
dq $$ ; p_vaddr
dq $$ ; p_paddr
dq filesize ; p_filesz
dq filesize ; p_memsz
dq 0x1000 ; p_align
phdrsize equ $ - phdr
_start:
mov rax, 1
mov rdi, 1
mov rsi, message
mov rdx, 13
syscall
mov rax, 60
xor rdi, rdi
syscall
message: db "hello, world", 0xa
filesize equ $ - $$
還是使用 nasm,不過這一次,我們使用 nasm -f bin 直接得到二進制程序。
最終結果是 170 個字節,這 170 字節的程序發送給任意的 x64 架構的 64 位 Linux,都可以打印出 hello world。
結束了,塵埃落定。
Tip: 其實還可以繼續,還有一些技巧可以進一步減小體積,因爲非常的「Hack」,這裏不打算說明了。有興趣的朋友可以參考《A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux》。
**Final Binary Anatomy
**
最後我們來看一下這 170 字節中每一個字節是什麼,在做什麼,真正地做到對每一個字節都瞭然於胸。
# ELF Header
00: 7f 45 4c 46 02 01 01 00 # e_ident
08: 00 00 00 00 00 00 00 00 # reserved
10: 02 00 # e_type
12: 3e 00 # e_machine
14: 01 00 00 00 # e_version
18: 78 00 40 00 00 00 00 00 # e_entry
20: 40 00 00 00 00 00 00 00 # e_phoff
28: 00 00 00 00 00 00 00 00 # e_shoff
30: 00 00 00 00 # e_flags
34: 40 00 # e_ehsize
36: 38 00 # e_phentsize
38: 01 00 # e_phnum
3a: 00 00 # e_shentsize
3c: 00 00 # e_shnum
3e: 00 00 # e_shstrndx
# Program Header
40: 01 00 00 00 # p_type
44: 05 00 00 00 # p_flags
48: 00 00 00 00 00 00 00 00 # p_offset
50: 00 00 40 00 00 00 00 00 # p_vaddr
58: 00 00 40 00 00 00 00 00 # p_paddr
60: aa 00 00 00 00 00 00 00 # p_filesz
68: aa 00 00 00 00 00 00 00 # p_memsz
70: 00 10 00 00 00 00 00 00 # p_align
# Code
78: b8 01 00 00 00 # mov $0x1,%eax
7d: bf 01 00 00 00 # mov $0x1,%edi
82: 48 be 9d 00 40 00 00 00 00 00 # movabs $0x40009d,%rsi
8c: ba 0d 00 00 00 # mov $0xd,%edx
91: 0f 05 # syscall
93: b8 3c 00 00 00 # mov $0x3c,%eax
98: 48 31 ff # xor %rdi,%rdi
9b: 0f 05 # syscall
9d: 68 65 6c 6c 6f 2c 20 77 6f 72 6c 64 0a # "hello, world\n"
可以發現 ELF Header 是 64 個字節,Program Header 是 56 字節,代碼 37 個字節,最後 13 個字節是 hello, world\n 這個字符串數據。
從上面的反彙編中我們可以看出 x86-64 和 ARM 比起來一個顯著的特點就是 x86-64 是變長指令集,每條指令的長度並不相等。長一點的 movabs 是 10 個字節,而短一點的 syscall 只有 2 個字節。
關於 x86-64,Intel 官方的手冊 Intel® 64 and IA-32 Architectures Software Developer Manuals 十分十分詳細,是每一個底層愛好者居家旅行的必備之物。
tiny-x64-helloworld 倉庫中有上面每一步的代碼和編譯指令,供大家參考~
最後,編譯、鏈接和裝載互聯網上有很多資料,這篇博客的目的並不是想要詳細地去介紹這裏面的知識,更多地是想作爲一個楔子,幫助大家建立一個整體的認識,從而挑選自己感興趣的部分去深入學習,祝大家 Happy Coding~
原文鏈接:https://cjting.me/2020/12/10/tiny-x64-helloworld/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/ofrSRkmKFe5H6WogQoDAew