程序員內(nèi)功修煉:五分鐘徹底搞懂 Linux ELF 文件 !
大家好啊,我是小康。
今天咱們來聊一個(gè)看起來高深,實(shí)際上理解起來其實(shí)挺簡單的話題—— ELF 文件。
不知道你有沒有想過:我們敲下./program命令的那一刻,計(jì)算機(jī)是怎么把這個(gè)文件變成一個(gè)活蹦亂跳的進(jìn)程的?這背后的"黑魔法"到底是什么?
沒錯(cuò),答案就是今天的主角:ELF(Executable and Linkable Format)可執(zhí)行與可鏈接格式。你可以把它理解為 Linux 世界里程序的"靈魂容器"!
一、什么是 ELF 文件?給個(gè)痛快話!
簡單來說,ELF 是 Linux 下的可執(zhí)行文件格式,就像 Windows 下的 .exe 一樣。但別被這個(gè)簡單的解釋騙了,ELF 可比 .exe 復(fù)雜得多,也強(qiáng)大得多!
ELF 文件可以是:
- 可執(zhí)行文件(比如你的./program)
- 目標(biāo)文件(編譯后但還沒鏈接的 .o 文件)
- 共享庫文件(就是 .so 文件,類似 Windows 下的 .dll)
- 核心轉(zhuǎn)儲(chǔ)文件(程序崩潰時(shí)的那個(gè)core dump)
本質(zhì)上,ELF 就是一個(gè)容器,里面裝著代碼、數(shù)據(jù)以及程序運(yùn)行所需的各種信息,按照特定的格式組織起來。
二、初見 ELF:第一印象很重要
想知道一個(gè)文件是不是 ELF 格式的?超簡單:
$ file /bin/ls
/bin/ls: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, ...
看到?jīng)]?只要文件輸出信息的開頭是"ELF",那它就是 ELF 格式的!
再來點(diǎn)兒硬核的,我們直接看一下 ELF 文件的前幾個(gè)字節(jié):
$ hexdump -C -n 16 /bin/ls
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
這里最開始的7f 45 4c 46就是 ELF 文件的"魔數(shù)"(Magic Number)。其中 45 4c 46 是 ASCII 碼中的 "ELF" 三個(gè)字母,前面的 7f 是一個(gè)特殊字符。這四個(gè)字節(jié)就是 ELF 文件的"身份證",操作系統(tǒng)首先會(huì)檢查這四個(gè)字節(jié),確認(rèn)它是不是一個(gè) ELF 文件。
三、ELF 文件的內(nèi)部結(jié)構(gòu):化繁為簡
很多教程一上來就給你畫個(gè)復(fù)雜的結(jié)構(gòu)圖,看得人頭暈眼花。咱們先別急,我用一個(gè)簡單的類比來幫你理解:
把 ELF 文件想象成一本"程序說明書",這本書有三部分組成:
- 文件頭(ELF Header):相當(dāng)于書的封面和目錄,告訴你這本書有什么內(nèi)容,怎么看
- 程序頭表(Program Header Table):相當(dāng)于給"閱讀器"(操作系統(tǒng))看的指南,告訴它怎么把這本書變成一個(gè)活的程序
- 節(jié)區(qū)頭表(Section Header Table):相當(dāng)于給"編輯器"(鏈接器、調(diào)試器)看的指南,告訴它這本書的內(nèi)部結(jié)構(gòu)
然后,書的主體內(nèi)容就是各種節(jié)區(qū)(Sections)或段(Segments),里面裝著代碼、數(shù)據(jù)等實(shí)際內(nèi)容。
直觀一點(diǎn),用圖來表示就是:
+------------------+
| ELF Header | <-- 文件開始處的標(biāo)識(shí)信息和總體布局
+------------------+
| 程序頭表 | <-- 告訴操作系統(tǒng)如何加載
| Program Header 1 |
| Program Header 2 |
| ... |
+------------------+
| Section 1 | <-- 實(shí)際內(nèi)容,如代碼、數(shù)據(jù)等
| Section 2 |
| ... |
+------------------+
| 節(jié)區(qū)頭表 | <-- 描述每個(gè)Section的信息
| Section Header 1 |
| Section Header 2 |
| ... |
+------------------+
哎,你可能會(huì)問:什么是節(jié)區(qū)(Section)?什么又是段(Segment)?它們有什么區(qū)別?
簡單來說:
- 節(jié)區(qū)(Section):是 ELF 文件存儲(chǔ)的基本單位,針對鏈接器
- 段(Segment):是運(yùn)行時(shí)內(nèi)存的基本單位,針對加載器
一個(gè)段通常包含多個(gè)功能相似的節(jié)區(qū)。比如,包含代碼的所有節(jié)區(qū)會(huì)被歸入到一個(gè)叫做"TEXT"的段中。
四、深入解剖 ELF文件:逐層剝開
1. ELF頭(ELF Header)
ELF 頭是整個(gè)文件的"門面",包含了文件的基本信息和指向其他部分的指針。用readelf -h命令可以查看:
$ readelf -h /bin/ls
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x5850
Start of program headers: 64 (bytes into file)
Start of section headers: 136912 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 30
Section header string table index: 29
這里面最重要的信息是:
- Entry point address:程序執(zhí)行的起點(diǎn)地址
- Start of program headers:程序頭表的位置
- Start of section headers:節(jié)區(qū)頭表的位置
2. 程序頭表(Program Header Table)
程序頭表告訴操作系統(tǒng)如何創(chuàng)建進(jìn)程映像,用readelf -l命令查看:
$ readelf -l /bin/ls
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002d8 0x00000000000002d8 R 0x8
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000004428 0x0000000000004428 R 0x1000
LOAD 0x0000000000005000 0x0000000000005000 0x0000000000005000
0x0000000000012d1c 0x0000000000012d1c R E 0x1000
LOAD 0x0000000000018000 0x0000000000018000 0x0000000000018000
0x0000000000004d40 0x0000000000004d40 R 0x1000
LOAD 0x000000000001d520 0x000000000001e520 0x000000000001e520
0x0000000000001640 0x0000000000002270 RW 0x1000
...
最重要的是那些類型為LOAD的段,它們會(huì)被加載到內(nèi)存中。
注意看Flags:
- R表示可讀(Read)
- W表示可寫(Write)
- E表示可執(zhí)行(Execute)
這就是為什么有的內(nèi)存區(qū)域可執(zhí)行,有的只能讀不能寫,這些權(quán)限在 ELF 文件里就定義好了!
3. 節(jié)區(qū)頭表(Section Header Table)
節(jié)區(qū)頭表描述了文件中各個(gè)節(jié)區(qū)的信息,用readelf -S查看:
$ readelf -S /bin/ls
There are 30 section headers, starting at offset 0x21730:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000000318 00000318
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.gnu.build-i NOTE 0000000000000338 00000338
0000000000000024 0000000000000000 A 0 0 4
...
[11] .text PROGBITS 00000000000052a0 000052a0
0000000000012a7c 0000000000000000 AX 0 0 16
...
[23] .data PROGBITS 000000000001e520 0001d520
0000000000000e60 0000000000000000 WA 0 0 32
[24] .bss NOBITS 000000000001f380 0001e380
0000000000001410 0000000000000000 WA 0 0 32
...
常見的重要節(jié)區(qū)包括:
- .text:存放程序的機(jī)器代碼
- .data:已初始化的全局變量和靜態(tài)變量
- .bss:未初始化的全局變量和靜態(tài)變量(不占用文件空間)
- .rodata:只讀數(shù)據(jù)(如字符串常量)
- .symtab:符號(hào)表,存儲(chǔ)程序中定義和引用的函數(shù)、變量
- .strtab:字符串表,通常存儲(chǔ)符號(hào)名
- .dynamic:動(dòng)態(tài)鏈接信息
五、ELF 文件的生命周期:從編譯到執(zhí)行
為了徹底搞懂 ELF 文件,我們需要了解它的整個(gè)生命周期:
源代碼(.c) --編譯--> 目標(biāo)文件(.o) --鏈接--> 可執(zhí)行文件 --加載--> 進(jìn)程
1. 編譯階段:生成目標(biāo)文件(.o)
當(dāng)你寫完 C 代碼,運(yùn)行g(shù)cc -c hello.c時(shí),會(huì)得到一個(gè)hello.o的目標(biāo)文件。這個(gè)文件已經(jīng)是 ELF 格式的了,但它還不能直接執(zhí)行,因?yàn)槔锩嬗泻芏?坑"等著被填上。
這些"坑"在 ELF 文件中表現(xiàn)為"重定位表",用readelf -r可以看到:
$ readelf -r hello.o
Relocation section '.rela.text' at offset 0x2d0 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000013 000a00000004 R_X86_64_PLT32 0000000000000000 printf - 4
000000000023 000b00000004 R_X86_64_PLT32 0000000000000000 exit - 4
這表示代碼中調(diào)用了printf和exit函數(shù),但編譯器不知道它們在哪兒,所以留了個(gè)"坑"等著鏈接器來填。
2. 符號(hào)表:程序的"通訊錄"
說到這些函數(shù)(printf 、exit),咱們不得不提 ELF 文件中的"符號(hào)表"。簡單來說,符號(hào)表就像是程序的"通訊錄",記錄了程序中所有函數(shù)和變量的名字和位置。
來看看符號(hào)表長啥樣:
$ readelf -s hello.o
Symbol table '.symtab' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS hello.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
...
9: 0000000000000000 41 FUNC GLOBAL DEFAULT 1 main
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND exit
瞧,這里面有main函數(shù)(我們自己定義的),還有printf和exit(外部函數(shù))。注意它們的Ndx(索引)列:main是1,表示在第1個(gè)節(jié)區(qū);而printf和exit是UND,表示"未定義",這就是前面說的"坑"。
這個(gè)目標(biāo)文件的符號(hào)表就像一張"半成品通訊錄",只記錄了自己有什么函數(shù),以及自己需要哪些外部函數(shù),但還不知道那些外部函數(shù)在哪里。所以它還不能獨(dú)立工作,需要鏈接器來幫忙找到這些外部函數(shù)。
3. 動(dòng)態(tài)鏈接:程序的"即插即用"
說到外部函數(shù),就不得不提 ELF 的一個(gè)超強(qiáng)功能:動(dòng)態(tài)鏈接。
還記得 Windows 上安裝軟件時(shí)經(jīng)常冒出的"DLL缺失"錯(cuò)誤嗎?Linux 上也有類似概念,不過實(shí)現(xiàn)得更優(yōu)雅,這就是動(dòng)態(tài)鏈接庫(.so文件)。
動(dòng)態(tài)鏈接的好處簡直不要太多:
- 節(jié)省內(nèi)存:多個(gè)程序共享同一個(gè)庫
- 節(jié)省磁盤:不用把所有代碼都打包進(jìn)可執(zhí)行文件
- 方便升級:庫更新后,程序自動(dòng)用上新版本,不用重新編譯
那么問題來了:程序怎么知道自己需要哪些庫?又是如何找到這些庫的呢?
ELF 文件中有一個(gè)特殊的.dynamic節(jié)區(qū),專門記錄這些信息:
$ readelf -d /bin/ls | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libselinux.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
這告訴我們,ls命令依賴于這兩個(gè)共享庫。如果你想更直觀地看到所有依賴及它們的實(shí)際位置,可以用ldd命令:
$ ldd /bin/ls
linux-vdso.so.1 (0x00007ffc961cd000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f27f989e000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f27f96b3000)
...
看到?jīng)]?ldd不僅告訴你需要哪些庫,還告訴你它們的實(shí)際位置和加載地址。
那程序又是怎么找到這些庫的呢?它會(huì)按照以下順序查找:
- 環(huán)境變量LD_LIBRARY_PATH指定的目錄
- 可執(zhí)行文件的RPATH屬性指定的目錄
- /etc/ld.so.cache緩存中記錄的位置
- 默認(rèn)目錄如/lib、/usr/lib等
動(dòng)態(tài)鏈接器(ld.so)會(huì)在程序啟動(dòng)時(shí)自動(dòng)處理這些依賴關(guān)系,把所有需要的庫都加載進(jìn)來,就像樂高積木一樣把程序拼裝完整,非常巧妙!
4. 鏈接階段:生成可執(zhí)行文件
鏈接器會(huì)把多個(gè)目標(biāo)文件和庫文件鏈接在一起,解決那些"坑"(重定位),最終生成可執(zhí)行文件。
那么鏈接器具體是怎么解決這些"坑"的呢?簡單來說就是做個(gè)"牽線搭橋"的活:
- 收集所有目標(biāo)文件中的符號(hào)表,建立一個(gè)全局符號(hào)表
- 找到所有標(biāo)記為"未定義"(UND)的符號(hào)
- 在全局符號(hào)表或者庫文件中尋找這些符號(hào)的定義
- 把找到的地址填回原來的"坑"中
比如當(dāng)鏈接器找到printf函數(shù)在 libc.so 中的實(shí)際地址后,就會(huì)修改原來調(diào)用 printf 的指令,讓它指向正確的地址。
鏈接完成后,再看同一個(gè)程序的符號(hào)表,會(huì)發(fā)現(xiàn)那些 UND 的符號(hào)要么有了實(shí)際地址(靜態(tài)鏈接),要么指向了動(dòng)態(tài)鏈接的跳轉(zhuǎn)表(動(dòng)態(tài)鏈接)。
在動(dòng)態(tài)鏈接的情況下,還會(huì)在 ELF 文件中記錄運(yùn)行時(shí)需要哪些共享庫,前面已經(jīng)說過了。
5. 加載階段:從文件到進(jìn)程
當(dāng)你執(zhí)行./program時(shí),操作系統(tǒng)(確切地說是加載器 ld.so )會(huì)做這些事:
- 檢查 ELF 頭的合法性
- 根據(jù)程序頭表,將需要的段加載到內(nèi)存
- 如果是動(dòng)態(tài)鏈接的,還會(huì)找到并加載所需的共享庫
- 跳轉(zhuǎn)到 Entry Point 開始執(zhí)行
這個(gè)過程可以用strace命令觀察:
$ strace ./hello
execve("./hello", ["./hello"], 0x7ffcef8db490 /* 52 vars */) = 0
brk(NULL) = 0x55c84f34c000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
...
execve就是創(chuàng)建新進(jìn)程的系統(tǒng)調(diào)用,后面一系列操作就是在加載和準(zhǔn)備程序運(yùn)行環(huán)境。
六、ELF實(shí)用工具箱:玩轉(zhuǎn)ELF文件
好了,了解了 ELF 的原理后,來看看有哪些工具可以幫我們操作 ELF 文件:
(1) file:判斷文件類型
$ file /bin/ls
(2) readelf:查看ELF文件的所有信息
$ readelf -a /bin/ls # 顯示全部信息
(3) objdump:反匯編 ELF 文件
$ objdump -d /bin/ls # 反匯編代碼段
(4) nm:列出符號(hào)表
$ nm /bin/ls # 顯示符號(hào)(函數(shù)、變量)
(5) ldd:查看動(dòng)態(tài)依賴
$ ldd /bin/ls # 顯示依賴的共享庫
(6) strings:提取文件中的字符串
$ strings /bin/ls | grep "GNU" # 查找包含"GNU"的字符串
(7) strip:移除ELF文件中的符號(hào)表和調(diào)試信息
$ strip -s program # 減小文件體積
(8) patchelf:修改 ELF 文件的屬性
$ patchelf --set-interpreter /lib64/ld-custom.so program # 修改解釋器
七、實(shí)際應(yīng)用:ELF文件的那些神奇玩法
ELF文件的知識(shí)不僅僅是理論,來看看一些實(shí)際的例子:
1. 程序加固與混淆
想象你開發(fā)了一個(gè)軟件不想被輕易破解:
# 刪除符號(hào)表,讓逆向分析更困難
$ strip --strip-all myprogram
# 對比前后大小
$ ls -lh myprogram*
-rwxr-xr-x 1 user user 236K myprogram
-rwxr-xr-x 1 user user 176K myprogram.stripped
看,文件體積一下減少了幾十k,因?yàn)榉?hào)信息都被刪掉了!
2. 程序補(bǔ)丁與熱修復(fù)
假設(shè)你想修改程序使用的解釋器路徑:
# 查看當(dāng)前解釋器
$ readelf -l myprogram | grep interpreter
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
# 修改為自定義解釋器
$ patchelf --set-interpreter /opt/mylibs/ld-linux.so myprogram
# 確認(rèn)修改成功
$ readelf -l myprogram | grep interpreter
[Requesting program interpreter: /opt/mylibs/ld-linux.so]
這樣程序就會(huì)使用你自定義的動(dòng)態(tài)鏈接器,而不需要重新編譯!
更酷的是,Linux 還提供了一種不用重啟程序就能熱修復(fù)的黑科技——LD_PRELOAD環(huán)境變量!它可以讓你悄悄地"替換"程序中的函數(shù)實(shí)現(xiàn)。
來看一個(gè)簡單實(shí)用的例子 —— 監(jiān)控程序的內(nèi)存分配:
創(chuàng)建一個(gè)簡單的內(nèi)存跟蹤庫: memtrace.c
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
// 原始malloc函數(shù)指針
staticvoid* (*real_malloc)(size_t) = NULL;
// 攔截 malloc 函數(shù)
void* malloc(size_t size) {
// 延遲初始化原始函數(shù)
if (real_malloc == NULL) {
real_malloc = dlsym(RTLD_NEXT, "malloc");
}
// 調(diào)用原始malloc
void* ptr = real_malloc(size);
// 打印跟蹤信息
fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);
return ptr;
}
編譯成共享庫:
$ gcc -shared -fPIC memtrace.c -o libmemtrace.so -ldl
接著使用我們的庫監(jiān)控任何程序的內(nèi)存分配:
LD_PRELOAD=./libmemtrace.so ./my_program
輸出:
malloc(100) = 0x55e930e2f6b0
malloc(200) = 0x55e930e2f720
malloc(300) = 0x55e930e2f7f0
看到了嗎?我們只用了十幾行代碼,就實(shí)現(xiàn)了一個(gè)能夠監(jiān)控任何程序內(nèi)存分配的工具!這個(gè)例子的工作原理很簡單:
- 定義一個(gè)與系統(tǒng)函數(shù)同名的malloc
- 用dlsym(RTLD_NEXT, "malloc")找到真正的 malloc 函數(shù)
- 在調(diào)用真正的 malloc 前后添加我們的代碼(這里是打印日志)
- 通過LD_PRELOAD讓系統(tǒng)優(yōu)先加載我們的庫
這種技術(shù)經(jīng)常用于:
- 調(diào)試內(nèi)存問題
- 給程序添加日志
- 修改程序行為而不用改源碼
- 臨時(shí)修復(fù)運(yùn)行中的服務(wù)
當(dāng)然,這項(xiàng)技術(shù)也常被黑客利用來劫持程序函數(shù),所以理解它不僅能提升編程能力,也對安全防護(hù)很重要!
八、總結(jié):ELF 文件的精髓
好了,咱們來總結(jié)一下 ELF 文件的核心要點(diǎn):
(1) ELF是容器:裝載了代碼、數(shù)據(jù)和各種元數(shù)據(jù)
(2) 分層結(jié)構(gòu):ELF 頭、程序頭表、節(jié)區(qū)、節(jié)區(qū)頭表
(3) 兩種視角:
- 執(zhí)行視角:段(Segments)- 加載器關(guān)心
- 鏈接視角:節(jié)(Sections)- 鏈接器關(guān)心
(4) 生命周期:從源代碼到目標(biāo)文件,再到可執(zhí)行文件,最后變成進(jìn)程
當(dāng)你理解了 ELF 文件的本質(zhì),Linux 下的很多問題就迎刃而解了:為什么有些程序不能在不同版本的 Linux 上運(yùn)行?為什么動(dòng)態(tài)庫版本不匹配會(huì)導(dǎo)致程序崩潰?為什么有些惡意軟件難以檢測?——這些問題的答案都藏在 ELF 文件的結(jié)構(gòu)中!
記住,ELF 文件不僅僅是一個(gè)格式,它是 Linux 世界中程序的"靈魂容器",承載著程序從編譯到執(zhí)行的整個(gè)生命周期。