平時在 Linux 下寫代碼,直接用 gcc -o out in.c
就把代碼編譯好了,但是這背后到底做了什么呢?
如果學習過《編譯原理》則不難理解,一般高級語言程序編譯的過程莫過于:預處理、編譯、匯編、鏈接。
gcc
在后臺實際上也經(jīng)歷了這幾個過程,可以通過 -v
參數(shù)查看它的編譯細節(jié),如果想看某個具體的編譯過程,則可以分別使用 -E
,-S
,-c
和 -O
,對應的后臺工具則分別為 cpp
,cc1
,as
,ld
。
下面將逐步分析這幾個過程以及相關的內(nèi)容,諸如語法檢查、代碼調(diào)試、匯編語言等。
預處理是 C 語言程序從源代碼變成可執(zhí)行程序的第一步,主要是 C 語言編譯器對各種預處理命令進行處理,包括頭文件的包含、宏定義的擴展、條件編譯的選擇等。
以前沒怎么“深入”預處理,腦子對這些東西總是很模糊,只記得在編譯的基本過程(詞法分析、語法分析)之前還需要對源代碼中的宏定義、文件包含、條件編譯等命令進行處理。這三類的指令很常見,主要有 #define
,#include
和 #ifdef ... #endif
,要特別地注意它們的用法。
#define
除了可以獨立使用以便靈活設置一些參數(shù)外,還常常和 #ifdef ... #endif
結(jié)合使用,以便靈活地控制代碼塊的編譯與否,也可以用來避免同一個頭文件的多次包含。關于 #include
貌似比較簡單,通過 man
找到某個函數(shù)的頭文件,復制進去,加上 <>
就好。這里雖然只關心一些技巧,不過預處理還是隱藏著很多潛在的陷阱(可參考《C Traps & Pitfalls》)也是需要注意的。下面僅介紹和預處理相關的幾個簡單內(nèi)容。
$ gcc -E hello.c
這樣就可以看到源代碼中的各種預處理命令是如何被解釋的,從而方便理解和查錯。
實際上 gcc
在這里調(diào)用了 cpp
(雖然通過 gcc -v
僅看到 cc1
),cpp
即 The C Preprocessor,主要用來預處理宏定義、文件包含、條件編譯等。下面介紹它的一個比較重要的選項 -D
。
$ gcc -Dmacro hello.c
這個等同于在文件的開頭定義宏,即 #define macro
,但是在命令行定義更靈活。例如,在源代碼中有這些語句。
#ifdef DEBUG
printf("this code is for debugging\n");
#endif
如果編譯時加上 -DDEBUG
選項,那么編譯器就會把 printf
所在的行編譯進目標代碼,從而方便地跟蹤該位置的某些程序狀態(tài)。這樣 -DDEBUG
就可以當作一個調(diào)試開關,編譯時加上它就可以用來打印調(diào)試信息,發(fā)布時則可以通過去掉該編譯選項把調(diào)試信息去掉。
編譯之前,C 語言編譯器會進行詞法分析、語法分析,接著會把源代碼翻譯成中間語言,即匯編語言。如果想看到這個中間結(jié)果,可以用 gcc -S
。需要提到的是,諸如 Shell 等解釋語言也會經(jīng)歷一個詞法分析和語法分析的階段,不過之后并不會進行“翻譯”,而是“解釋”,邊解釋邊執(zhí)行。
把源代碼翻譯成匯編語言,實際上是編譯的整個過程中的第一個階段,之后的階段和匯編語言的開發(fā)過程沒有什么區(qū)別。這個階段涉及到對源代碼的詞法分析、語法檢查(通過 -std
指定遵循哪個標準),并根據(jù)優(yōu)化(-O
)要求進行翻譯成匯編語言的動作。
如果僅僅希望進行語法檢查,可以用 gcc
的 -fsyntax-only
選項;如果為了使代碼有比較好的可移植性,避免使用 gcc
的一些擴展特性,可以結(jié)合 -std
和 -pedantic
(或者 -pedantic-erros
)選項讓源代碼遵循某個 C 語言標準的語法。這里演示一個簡單的例子:
$ cat hello.c
#include <stdio.h>
int main()
{
printf("hello, world\n")
return 0;
}
$ gcc -fsyntax-only hello.c
hello.c: In function ‘main’:
hello.c:5: error: expected ‘;’ before ‘return’
$ vim hello.c
$ cat hello.c
#include <stdio.h>
int main()
{
printf("hello, world\n");
int i;
return 0;
}
$ gcc -std=c89 -pedantic-errors hello.c #默認情況下,gcc是允許在程序中間聲明變量的,但是turboc就不支持
hello.c: In function ‘main’:
hello.c:5: error: ISO C90 forbids mixed declarations and code
語法錯誤是程序開發(fā)過程中難以避免的錯誤(人的大腦在很多情況下都容易開小差),不過編譯器往往能夠通過語法檢查快速發(fā)現(xiàn)這些錯誤,并準確地告知語法錯誤的大概位置。因此,作為開發(fā)人員,要做的事情不是“恐慌”(不知所措),而是認真閱讀編譯器的提示,根據(jù)平時積累的經(jīng)驗(最好總結(jié)一份常見語法錯誤索引,很多資料都提供了常見語法錯誤列表,如《C Traps & Pitfalls》和編輯器提供的語法檢查功能(語法加亮、括號匹配提示等)快速定位語法出錯的位置并進行修改。
語法檢查之后就是翻譯動作,gcc
提供了一個優(yōu)化選項 -O
,以便根據(jù)不同的運行平臺和用戶要求產(chǎn)生經(jīng)過優(yōu)化的匯編代碼。例如,
$ gcc -o hello hello.c # 采用默認選項,不優(yōu)化
$ gcc -O2 -o hello2 hello.c # 優(yōu)化等次是2
$ gcc -Os -o hellos hello.c # 優(yōu)化目標代碼的大小
$ ls -S hello hello2 hellos # 可以看到,hellos 比較小, hello2 比較大
hello2 hello hellos
$ time ./hello
hello, world
real 0m0.001s
user 0m0.000s
sys 0m0.000s
$ time ./hello2 # 可能是代碼比較少的緣故,執(zhí)行效率看上去不是很明顯
hello, world
real 0m0.001s
user 0m0.000s
sys 0m0.000s
$ time ./hellos # 雖然目標代碼小了,但是執(zhí)行效率慢了些
hello, world
real 0m0.002s
user 0m0.000s
sys 0m0.000s
根據(jù)上面的簡單演示,可以看出 gcc
有很多不同的優(yōu)化選項,主要看用戶的需求了,目標代碼的大小和效率之間貌似存在一個“糾纏”,需要開發(fā)人員自己權(quán)衡。
下面通過 -S
選項來看看編譯出來的中間結(jié)果:匯編語言,還是以之前那個 hello.c
為例。
$ gcc -S hello.c # 默認輸出是hello.s,可自己指定,輸出到屏幕`-o -`,輸出到其他文件`-o file`
$ cat hello.s
cat hello.s
.file "hello.c"
.section .rodata
.LC0:
.string "hello, world"
.text
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $4, %esp
movl $.LC0, (%esp)
call puts
movl $0, %eax
addl $4, %esp
popl %ecx
popl %ebp
leal -4(%ecx), %esp
ret
.size main, .-main
.ident "GCC: (GNU) 4.1.3 20070929 (prerelease) (Ubuntu 4.1.2-16ubuntu2)"
.section .note.GNU-stack,"",@progbits
不知道看出來沒?和課堂里學的 intel 的匯編語法不太一樣,這里用的是 AT&T
語法格式。如果想學習 Linux 下的匯編語言開發(fā),下一節(jié)開始的所有章節(jié)基本上覆蓋了 Linux 下匯編語言開發(fā)的一般過程,不過這里不介紹匯編語言語法。
在學習后面的章節(jié)之前,建議自學舊金山大學的微機編程課程 CS 630,該課深入介紹了 Linux/X86 平臺下的 AT&T
匯編語言開發(fā)。如果想在 Qemu
上做這個課程里的實驗,可以閱讀本文作者寫的 CS630: Linux 下通過 Qemu 學習 X86 AT&T 匯編語言。
需要補充的是,在寫 C 語言代碼時,如果能夠?qū)幾g器比較熟悉(工作原理和一些細節(jié))的話,可能會很有幫助。包括這里的優(yōu)化選項(有些優(yōu)化選項可能在匯編時采用)和可能的優(yōu)化措施,例如字節(jié)對齊、條件分支語句裁減(刪除一些明顯分支)等。
匯編實際上還是翻譯過程,只不過把作為中間結(jié)果的匯編代碼翻譯成了機器代碼,即目標代碼,不過它還不可以運行。如果要產(chǎn)生這一中間結(jié)果,可用 gcc -c
,當然,也可通過 as
命令處理匯編語言源文件來產(chǎn)生。
匯編是把匯編語言翻譯成目標代碼的過程,如果有在 Windows 下學習過匯編語言開發(fā),大家應該比較熟悉 nasm
匯編工具(支持 Intel 格式的匯編語言),不過這里主要用 as
匯編工具來匯編 AT&T
格式的匯編語言,因為 gcc
產(chǎn)生的中間代碼就是 AT&T
格式的。
下面來演示分別通過 gcc -c
選項和 as
來產(chǎn)生目標代碼。
$ file hello.s
hello.s: ASCII assembler program text
$ gcc -c hello.s #用gcc把匯編語言編譯成目標代碼
$ file hello.o #file命令用來查看文件類型,目標代碼可重定位的(relocatable),
#需要通過ld進行進一步鏈接成可執(zhí)行程序(executable)和共享庫(shared)
hello.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
$ as -o hello.o hello.s #用as把匯編語言編譯成目標代碼
$ file hello.o
hello.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
gcc
和 as
默認產(chǎn)生的目標代碼都是 ELF 格式的,因此這里主要討論ELF格式的目標代碼(如果有時間再回顧一下 a.out
和 coff
格式,當然也可以先了解一下,并結(jié)合 objcopy
來轉(zhuǎn)換它們,比較異同)。
目標代碼不再是普通的文本格式,無法直接通過文本編輯器瀏覽,需要一些專門的工具。如果想了解更多目標代碼的細節(jié),區(qū)分 relocatable
(可重定位)、executable
(可執(zhí)行)、shared libarary
(共享庫)的不同,我們得設法了解目標代碼的組織方式和相關的閱讀和分析工具。下面主要介紹這部分內(nèi)容。
BFD is a package which allows applications to use the same routines tooperate on object files whatever the object file format. A new object fileformat can be supported simply by creating a new BFD back end and adding it tothe library.
binutils(GNU Binary Utilities)的很多工具都采用這個庫來操作目標文件,這類工具有 objdump
,objcopy
,nm
,strip
等(當然,我們也可以利用它。如果深入了解ELF格式,那么通過它來分析和編寫 Virus 程序?qū)臃奖悖贿^另外一款非常優(yōu)秀的分析工具 readelf
并不是基于這個庫,所以也應該可以直接用 elf.h
頭文件中定義的相關結(jié)構(gòu)來操作 ELF 文件。
下面將通過這些輔助工具(主要是 readelf
和 objdump
),結(jié)合 ELF 手冊來分析它們。將依次介紹 ELF 文件的結(jié)構(gòu)和三種不同類型 ELF 文件的區(qū)別。
ELF Header(ELF文件頭)
Program Headers Table(程序頭表,實際上叫段表好一些,用于描述可執(zhí)行文件和可共享庫)
Section 1
Section 2
Section 3
...
Section Headers Table(節(jié)區(qū)頭部表,用于鏈接可重定位文件成可執(zhí)行文件或共享庫)
對于可重定位文件,程序頭是可選的,而對于可執(zhí)行文件和共享庫文件(動態(tài)鏈接庫),節(jié)區(qū)表則是可選的??梢苑謩e通過 readelf
文件的 -h
,-l
和 -S
參數(shù)查看 ELF 文件頭(ELF Header)、程序頭部表(Program Headers Table,段表)和節(jié)區(qū)表(Section Headers Table)。
文件頭說明了文件的類型,大小,運行平臺,節(jié)區(qū)數(shù)目等。
先來通過文件頭看看不同ELF的類型。為了說明問題,先來幾段代碼吧。
/* myprintf.c */
#include <stdio.h>
void myprintf(void)
{
printf("hello, world!\n");
}
/* test.h -- myprintf function declaration */
#ifndef _TEST_H_
#define _TEST_H_
void myprintf(void);
#endif
/* test.c */
#include "test.h"
int main()
{
myprintf();
return 0;
}
下面通過這幾段代碼來演示通過 readelf -h
參數(shù)查看 ELF 的不同類型。期間將演示如何創(chuàng)建動態(tài)鏈接庫(即可共享文件)、靜態(tài)鏈接庫,并比較它們的異同。
編譯產(chǎn)生兩個目標文件 myprintf.o
和 test.o
,它們都是可重定位文件(REL):
$ gcc -c myprintf.c test.c
$ readelf -h test.o | grep Type
Type: REL (Relocatable file)
$ readelf -h myprintf.o | grep Type
Type: REL (Relocatable file)
根據(jù)目標代碼鏈接產(chǎn)生可執(zhí)行文件,這里的文件類型是可執(zhí)行的(EXEC):
$ gcc -o test myprintf.o test.o
$ readelf -h test | grep Type
Type: EXEC (Executable file)
用 ar
命令創(chuàng)建一個靜態(tài)鏈接庫,靜態(tài)鏈接庫也是可重定位文件(REL):
$ ar rcsv libmyprintf.a myprintf.o
$ readelf -h libmyprintf.a | grep Type
Type: REL (Relocatable file)
可見,靜態(tài)鏈接庫和可重定位文件類型一樣,它們之間唯一不同是前者可以是多個可重定位文件的“集合”。
靜態(tài)鏈接庫可直接鏈接(只需庫名,不要前面的 lib
),也可用 -l
參數(shù),-L
指定庫搜索路徑。
$ gcc -o test test.o -lmyprintf -L./
編譯產(chǎn)生動態(tài)鏈接庫,并支持 major
和 minor
版本號,動態(tài)鏈接庫類型為 DYN
:
$ gcc -Wall myprintf.o -shared -Wl,-soname,libmyprintf.so.0 -o libmyprintf.so.0.0
$ ln -sf libmyprintf.so.0.0 libmyprintf.so.0
$ ln -sf libmyprintf.so.0 libmyprintf.so
$ readelf -h libmyprintf.so | grep Type
Type: DYN (Shared object file)
動態(tài)鏈接庫編譯時和靜態(tài)鏈接庫類似:
$ gcc -o test test.o -lmyprintf -L./
但是執(zhí)行時需要指定動態(tài)鏈接庫的搜索路徑,把 LD_LIBRARY_PATH
設為當前目錄,指定 test
運行時的動態(tài)鏈接庫搜索路徑:
$ LD_LIBRARY_PATH=./ ./test
$ gcc -static -o test test.o -lmyprintf -L./
在不指定 -static
時會優(yōu)先使用動態(tài)鏈接庫,指定時則阻止使用動態(tài)鏈接庫,這時會把所有靜態(tài)鏈接庫文件加入到可執(zhí)行文件中,使得執(zhí)行文件很大,而且加載到內(nèi)存以后會浪費內(nèi)存空間,因此不建議這么做。
經(jīng)過上面的演示基本可以看出它們之間的不同:
從這個意義上說,動態(tài)鏈接庫本身也具有可重定位的特征,含有可重定位的信息。對于什么是重定位?如何進行靜態(tài)符號和動態(tài)符號的重定位,我們將在鏈接部分和《動態(tài)符號鏈接的細節(jié)》一節(jié)介紹。
下面來看看 ELF 文件的主體內(nèi)容:節(jié)區(qū)(Section)。
ELF 文件具有很大的靈活性,它通過文件頭組織整個文件的總體結(jié)構(gòu),通過節(jié)區(qū)表 (Section Headers Table)和程序頭(Program Headers Table 或者叫段表)來分別描述可重定位文件和可執(zhí)行文件。但不管是哪種類型,它們都需要它們的主體,即各種節(jié)區(qū)。
在可重定位文件中,節(jié)區(qū)表描述的就是各種節(jié)區(qū)本身;而在可執(zhí)行文件中,程序頭描述的是由各個節(jié)區(qū)組成的段(Segment),以便程序運行時動態(tài)裝載器知道如何對它們進行內(nèi)存映像,從而方便程序加載和運行。
下面先來看看一些常見的節(jié)區(qū),而關于這些節(jié)區(qū)(Section)如何通過重定位構(gòu)成不同的段(Segments),以及有哪些常規(guī)的段,我們將在鏈接部分進一步介紹。
可以通過 readelf -S
查看 ELF 的節(jié)區(qū)。(建議一邊操作一邊看文檔,以便加深對 ELF 文件結(jié)構(gòu)的理解)先來看看可重定位文件的節(jié)區(qū)信息,通過節(jié)區(qū)表來查看:
默認編譯好 myprintf.c
,將產(chǎn)生一個可重定位的文件 myprintf.o
,這里通過 myprintf.o
的節(jié)區(qū)表查看節(jié)區(qū)信息。
$ gcc -c myprintf.c
$ readelf -S myprintf.o
There are 11 section headers, starting at offset 0xc0:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034 000018 00 AX 0 0 4
[ 2] .rel.text REL 00000000 000334 000010 08 9 1 4
[ 3] .data PROGBITS 00000000 00004c 000000 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 00004c 000000 00 WA 0 0 4
[ 5] .rodata PROGBITS 00000000 00004c 00000e 00 A 0 0 1
[ 6] .comment PROGBITS 00000000 00005a 000012 00 0 0 1
[ 7] .note.GNU-stack PROGBITS 00000000 00006c 000000 00 0 0 1
[ 8] .shstrtab STRTAB 00000000 00006c 000051 00 0 0 1
[ 9] .symtab SYMTAB 00000000 000278 0000a0 10 10 8 4
[10] .strtab STRTAB 00000000 000318 00001a 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
用 objdump -d
可看反編譯結(jié)果,用 -j
選項可指定需要查看的節(jié)區(qū):
$ objdump -d -j .text myprintf.o
myprintf.o: file format elf32-i386
Disassembly of section .text:
00000000 <myprintf>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 ec 08 sub $0x8,%esp
6: 83 ec 0c sub $0xc,%esp
9: 68 00 00 00 00 push $0x0
e: e8 fc ff ff ff call f <myprintf+0xf>
13: 83 c4 10 add $0x10,%esp
16: c9 leave
17: c3 ret
用 -r
選項可以看到有關重定位的信息,這里有兩部分需要重定位:
$ readelf -r myprintf.o
Relocation section '.rel.text' at offset 0x334 contains 2 entries:
Offset Info Type Sym.Value Sym. Name
0000000a 00000501 R_386_32 00000000 .rodata
0000000f 00000902 R_386_PC32 00000000 puts
.rodata
節(jié)區(qū)包含只讀數(shù)據(jù),即我們要打印的 hello, world!
$ readelf -x .rodata myprintf.o
Hex dump of section '.rodata':
0x00000000 68656c6c 6f2c2077 6f726c64 2100 hello, world!.
沒有找到 .data
節(jié)區(qū), 它應該包含一些初始化的數(shù)據(jù):
$ readelf -x .data myprintf.o
Section '.data' has no data to dump.
也沒有 .bss
節(jié)區(qū),它應該包含一些未初始化的數(shù)據(jù),程序默認初始為 0:
$ readelf -x .bss myprintf.o
Section '.bss' has no data to dump.
.comment
是一些注釋,可以看到是是 Gcc
的版本信息
$ readelf -x .comment myprintf.o
Hex dump of section '.comment':
0x00000000 00474343 3a202847 4e552920 342e312e .GCC: (GNU) 4.1.
0x00000010 3200 2.
.note.GNU-stack
這個節(jié)區(qū)也沒有內(nèi)容:
$ readelf -x .note.GNU-stack myprintf.o
Section '.note.GNU-stack' has no data to dump.
.shstrtab
包括所有節(jié)區(qū)的名字:
$ readelf -x .shstrtab myprintf.o
Hex dump of section '.shstrtab':
0x00000000 002e7379 6d746162 002e7374 72746162 ..symtab..strtab
0x00000010 002e7368 73747274 6162002e 72656c2e ..shstrtab..rel.
0x00000020 74657874 002e6461 7461002e 62737300 text..data..bss.
0x00000030 2e726f64 61746100 2e636f6d 6d656e74 .rodata..comment
0x00000040 002e6e6f 74652e47 4e552d73 7461636b ..note.GNU-stack
0x00000050 00 .
符號表 .symtab
包括所有用到的相關符號信息,如函數(shù)名、變量名,可用 readelf
查看:
$ readelf -symtab myprintf.o
Symbol table '.symtab' contains 10 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 FILE LOCAL DEFAULT ABS myprintf.c
2: 00000000 0 SECTION LOCAL DEFAULT 1
3: 00000000 0 SECTION LOCAL DEFAULT 3
4: 00000000 0 SECTION LOCAL DEFAULT 4
5: 00000000 0 SECTION LOCAL DEFAULT 5
6: 00000000 0 SECTION LOCAL DEFAULT 7
7: 00000000 0 SECTION LOCAL DEFAULT 6
8: 00000000 24 FUNC GLOBAL DEFAULT 1 myprintf
9: 00000000 0 NOTYPE GLOBAL DEFAULT UND puts
字符串表 .strtab
包含用到的字符串,包括文件名、函數(shù)名、變量名等:
$ readelf -x .strtab myprintf.o
Hex dump of section '.strtab':
0x00000000 006d7970 72696e74 662e6300 6d797072 .myprintf.c.mypr
0x00000010 696e7466 00707574 7300 intf.puts.
從上表可以看出,對于可重定位文件,會包含這些基本節(jié)區(qū) .text
, .rel.text
, .data
, .bss
, .rodata
, .comment
, .note.GNU-stack
, .shstrtab
, .symtab
和 .strtab
。
為了進一步理解這些節(jié)區(qū)和源代碼的關系,這里來看一看 myprintf.c
產(chǎn)生的匯編代碼。
$ gcc -S myprintf.c
$ cat myprintf.s
.file "myprintf.c"
.section .rodata
.LC0:
.string "hello, world!"
.text
.globl myprintf
.type myprintf, @function
myprintf:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
subl $12, %esp
pushl $.LC0
call puts
addl $16, %esp
leave
ret
.size myprintf, .-myprintf
.ident "GCC: (GNU) 4.1.2"
.section .note.GNU-stack,"",@progbits
是不是可以從中看出可重定位文件中的那些節(jié)區(qū)和匯編語言代碼之間的關系?在上面的可重定位文件,可以看到有一個可重定位的節(jié)區(qū),即 .rel.text
,它標記了兩個需要重定位的項,.rodata
和 puts
。這個節(jié)區(qū)將告訴編譯器這兩個信息在鏈接或者動態(tài)鏈接的過程中需要重定位, 具體如何重定位?將根據(jù)重定位項的類型,比如上面的 R_386_32
和 R_386_PC32
。
到這里,對可重定位文件應該有了一個基本的了解,下面將介紹什么是可重定位,可重定位文件到底是如何被鏈接生成可執(zhí)行文件和動態(tài)鏈接庫的,這個過程除了進行一些符號的重定位外,還進行了哪些工作呢?
重定位是將符號引用與符號定義進行鏈接的過程。因此鏈接是處理可重定位文件,把它們的各種符號引用和符號定義轉(zhuǎn)換為可執(zhí)行文件中的合適信息(一般是虛擬內(nèi)存地址)的過程。
鏈接又分為靜態(tài)鏈接和動態(tài)鏈接,前者是程序開發(fā)階段程序員用 ld
(gcc
實際上在后臺調(diào)用了 ld
)靜態(tài)鏈接器手動鏈接的過程,而動態(tài)鏈接則是程序運行期間系統(tǒng)調(diào)用動態(tài)鏈接器(ld-linux.so
)自動鏈接的過程。
比如,如果鏈接到可執(zhí)行文件中的是靜態(tài)鏈接庫 libmyprintf.a
,那么 .rodata
節(jié)區(qū)在鏈接后需要被重定位到一個絕對的虛擬內(nèi)存地址,以便程序運行時能夠正確訪問該節(jié)區(qū)中的字符串信息。而對于 puts
函數(shù),因為它是動態(tài)鏈接庫 libc.so
中定義的函數(shù),所以會在程序運行時通過動態(tài)符號鏈接找出 puts
函數(shù)在內(nèi)存中的地址,以便程序調(diào)用該函數(shù)。在這里主要討論靜態(tài)鏈接過程,動態(tài)鏈接過程見《動態(tài)符號鏈接的細節(jié)》。
靜態(tài)鏈接過程主要是把可重定位文件依次讀入,分析各個文件的文件頭,進而依次讀入各個文件的節(jié)區(qū),并計算各個節(jié)區(qū)的虛擬內(nèi)存位置,對一些需要重定位的符號進行處理,設定它們的虛擬內(nèi)存地址等,并最終產(chǎn)生一個可執(zhí)行文件或者是動態(tài)鏈接庫。這個鏈接過程是通過 ld
來完成的,ld
在鏈接時使用了一個鏈接腳本(linker script
),該鏈接腳本處理鏈接的具體細節(jié)。
由于靜態(tài)符號鏈接過程非常復雜,特別是計算符號地址的過程,考慮到時間關系,相關細節(jié)請參考 ELF 手冊。這里主要介紹可重定位文件中的節(jié)區(qū)(節(jié)區(qū)表描述的)和可執(zhí)行文件中段(程序頭描述的)的對應關系以及 gcc
編譯時采用的一些默認鏈接選項。
下面先來看看可執(zhí)行文件的節(jié)區(qū)信息,通過程序頭(段表)來查看,為了比較,先把 test.o
的節(jié)區(qū)表也列出:
$ readelf -S test.o
There are 10 section headers, starting at offset 0xb4:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034 000024 00 AX 0 0 4
[ 2] .rel.text REL 00000000 0002ec 000008 08 8 1 4
[ 3] .data PROGBITS 00000000 000058 000000 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 000058 000000 00 WA 0 0 4
[ 5] .comment PROGBITS 00000000 000058 000012 00 0 0 1
[ 6] .note.GNU-stack PROGBITS 00000000 00006a 000000 00 0 0 1
[ 7] .shstrtab STRTAB 00000000 00006a 000049 00 0 0 1
[ 8] .symtab SYMTAB 00000000 000244 000090 10 9 7 4
[ 9] .strtab STRTAB 00000000 0002d4 000016 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
$ gcc -o test test.o myprintf.o
$ readelf -l test
Elf file type is EXEC (Executable file)
Entry point 0x80482b0
There are 7 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x0047c 0x0047c R E 0x1000
LOAD 0x00047c 0x0804947c 0x0804947c 0x00104 0x00108 RW 0x1000
DYNAMIC 0x000490 0x08049490 0x08049490 0x000c8 0x000c8 RW 0x4
NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r
.rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame
03 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag
06
可發(fā)現(xiàn),test
和 test.o
,myprintf.o
相比,多了很多節(jié)區(qū),如 .interp
和 .init
等。另外,上表也給出了可執(zhí)行文件的如下幾個段(Segment):
PHDR
: 給出了程序表自身的大小和位置,不能出現(xiàn)一次以上。INTERP
: 因為程序中調(diào)用了 puts
(在動態(tài)鏈接庫中定義),使用了動態(tài)鏈接庫,因此需要動態(tài)裝載器/鏈接器(ld-linux.so
)LOAD
: 包括程序的指令,.text
等節(jié)區(qū)都映射在該段,只讀(R)LOAD
: 包括程序的數(shù)據(jù),.data
,.bss
等節(jié)區(qū)都映射在該段,可讀寫(RW)DYNAMIC
: 動態(tài)鏈接相關的信息,比如包含有引用的動態(tài)鏈接庫名字等信息NOTE
: 給出一些附加信息的位置和大小GNU_STACK
: 這里為空,應該是和GNU相關的一些信息這里的段可能包括之前的一個或者多個節(jié)區(qū),也就是說經(jīng)過鏈接之后原來的節(jié)區(qū)被重排了,并映射到了不同的段,這些段將告訴系統(tǒng)應該如何把它加載到內(nèi)存中。
從上表中,通過比較可執(zhí)行文件 test
中擁有的節(jié)區(qū)和可重定位文件(test.o
和 myprintf.o
)中擁有的節(jié)區(qū)后發(fā)現(xiàn),鏈接之后多了一些之前沒有的節(jié)區(qū),這些新的節(jié)區(qū)來自哪里?它們的作用是什么呢?先來通過 gcc -v
看看它的后臺鏈接過程。
把可重定位文件鏈接成可執(zhí)行文件:
$ gcc -v -o test test.o myprintf.o
Reading specs from /usr/lib/gcc/i486-slackware-linux/4.1.2/specs
Target: i486-slackware-linux
Configured with: ../gcc-4.1.2/configure --prefix=/usr --enable-shared
--enable-languages=ada,c,c++,fortran,java,objc --enable-threads=posix
--enable-__cxa_atexit --disable-checking --with-gnu-ld --verbose
--with-arch=i486 --target=i486-slackware-linux --host=i486-slackware-linux
Thread model: posix
gcc version 4.1.2
/usr/libexec/gcc/i486-slackware-linux/4.1.2/collect2 --eh-frame-hdr -m
elf_i386 -dynamic-linker /lib/ld-linux.so.2 -o test
/usr/lib/gcc/i486-slackware-linux/4.1.2/../../../crt1.o
/usr/lib/gcc/i486-slackware-linux/4.1.2/../../../crti.o
/usr/lib/gcc/i486-slackware-linux/4.1.2/crtbegin.o
-L/usr/lib/gcc/i486-slackware-linux/4.1.2
-L/usr/lib/gcc/i486-slackware-linux/4.1.2
-L/usr/lib/gcc/i486-slackware-linux/4.1.2/../../../../i486-slackware-linux/lib
-L/usr/lib/gcc/i486-slackware-linux/4.1.2/../../.. test.o myprintf.o -lgcc
--as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed
/usr/lib/gcc/i486-slackware-linux/4.1.2/crtend.o
/usr/lib/gcc/i486-slackware-linux/4.1.2/../../../crtn.o
從上述演示看出,gcc
在鏈接了我們自己的目標文件 test.o
和 myprintf.o
之外,還鏈接了 crt1.o
,crtbegin.o
等額外的目標文件,難道那些新的節(jié)區(qū)就來自這些文件?
另外 gcc
在進行了相關配置(./configure
)后,調(diào)用了 collect2
,卻并沒有調(diào)用 ld
,通過查找 gcc
文檔中和 collect2
相關的部分發(fā)現(xiàn) collect2
在后臺實際上還是去尋找 ld
命令的。為了理解 gcc
默認鏈接的后臺細節(jié),這里直接把 collect2
替換成 ld
,并把一些路徑換成絕對路徑或者簡化,得到如下的 ld
命令以及執(zhí)行的效果。
$ ld --eh-frame-hdr \
-m elf_i386 \
-dynamic-linker /lib/ld-linux.so.2 \
-o test \
/usr/lib/crt1.o /usr/lib/crti.o /usr/lib/gcc/i486-slackware-linux/4.1.2/crtbegin.o \
test.o myprintf.o \
-L/usr/lib/gcc/i486-slackware-linux/4.1.2 -L/usr/i486-slackware-linux/lib -L/usr/lib/ \
-lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed \
/usr/lib/gcc/i486-slackware-linux/4.1.2/crtend.o /usr/lib/crtn.o
$ ./test
hello, world!
不出所料,它完美地運行了。下面通過 ld
的手冊(man ld
)來分析一下這幾個參數(shù):
--eh-frame-hdr
要求創(chuàng)建一個 .eh_frame_hdr
節(jié)區(qū)(貌似目標文件test中并沒有這個節(jié)區(qū),所以不關心它)。
-m elf_i386
這里指定不同平臺上的鏈接腳本,可以通過 --verbose
命令查看腳本的具體內(nèi)容,如 ld -m elf_i386 --verbose
,它實際上被存放在一個文件中(/usr/lib/ldscripts
目錄下),我們可以去修改這個腳本,具體如何做?請參考 ld
的手冊。在后面我們將簡要提到鏈接腳本中是如何預定義變量的,以及這些預定義變量如何在我們的程序中使用。需要提到的是,如果不是交叉編譯,那么無須指定該選項。
-dynamic-linker /lib/ld-linux.so.2
指定動態(tài)裝載器/鏈接器,即程序中的 INTERP
段中的內(nèi)容。動態(tài)裝載器/鏈接器負責鏈接有可共享庫的可執(zhí)行文件的裝載和動態(tài)符號鏈接。
-o test
指定輸出文件,即可執(zhí)行文件名的名字
/usr/lib/crt1.o /usr/lib/crti.o /usr/lib/gcc/i486-slackware-linux/4.1.2/crtbegin.o
鏈接到 test
文件開頭的一些內(nèi)容,這里實際上就包含了 .init
等節(jié)區(qū)。.init
節(jié)區(qū)包含一些可執(zhí)行代碼,在 main
函數(shù)之前被調(diào)用,以便進行一些初始化操作,在 C++ 中完成構(gòu)造函數(shù)功能。
test.o myprintf.o
鏈接我們自己的可重定位文件
-L/usr/lib/gcc/i486-slackware-linux/4.1.2 -L/usr/i486-slackware-linux/lib -L/usr/lib/ \-lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed
鏈接 libgcc
庫和 libc
庫,后者定義有我們需要的 puts
函數(shù)
/usr/lib/gcc/i486-slackware-linux/4.1.2/crtend.o /usr/lib/crtn.o
鏈接到 test
文件末尾的一些內(nèi)容,這里實際上包含了 .fini
等節(jié)區(qū)。.fini
節(jié)區(qū)包含了一些可執(zhí)行代碼,在程序退出時被執(zhí)行,作一些清理工作,在 C++ 中完成析構(gòu)造函數(shù)功能。我們往往可以通過 atexit
來注冊那些需要在程序退出時才執(zhí)行的函數(shù)。
對于 crtbegin.o
和 crtend.o
這兩個文件,貌似完全是用來支持 C++ 的構(gòu)造和析構(gòu)工作的,所以可以不鏈接到我們的可執(zhí)行文件中,鏈接時把它們?nèi)サ艨纯矗?/p>
$ ld -m elf_i386 -dynamic-linker /lib/ld-linux.so.2 -o test \
/usr/lib/crt1.o /usr/lib/crti.o test.o myprintf.o \
-L/usr/lib -lc /usr/lib/crtn.o #后面發(fā)現(xiàn)不用鏈接libgcc,也不用--eh-frame-hdr參數(shù)
$ readelf -l test
Elf file type is EXEC (Executable file)
Entry point 0x80482b0
There are 7 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x003ea 0x003ea R E 0x1000
LOAD 0x0003ec 0x080493ec 0x080493ec 0x000e8 0x000e8 RW 0x1000
DYNAMIC 0x0003ec 0x080493ec 0x080493ec 0x000c8 0x000c8 RW 0x4
NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r
.rel.dyn .rel.plt .init .plt .text .fini .rodata
03 .dynamic .got .got.plt .data
04 .dynamic
05 .note.ABI-tag
06
$ ./test
hello, world!
完全可以工作,而且發(fā)現(xiàn) .ctors
(保存著程序中全局構(gòu)造函數(shù)的指針數(shù)組), .dtors
(保存著程序中全局析構(gòu)函數(shù)的指針數(shù)組),.jcr
(未知),.eh_frame
節(jié)區(qū)都沒有了,所以 crtbegin.o
和 crtend.o
應該包含了這些節(jié)區(qū)。
而對于另外兩個文件 crti.o
和 crtn.o
,通過 readelf -S
查看后發(fā)現(xiàn)它們都有 .init
和 .fini
節(jié)區(qū),如果我們不需要讓程序進行一些初始化和清理工作呢?是不是就可以不鏈接這個兩個文件?試試看。
$ ld -m elf_i386 -dynamic-linker /lib/ld-linux.so.2 -o test \
/usr/lib/crt1.o test.o myprintf.o -L/usr/lib/ -lc
/usr/lib/libc_nonshared.a(elf-init.oS): In function `__libc_csu_init':
(.text+0x25): undefined reference to `_init'
貌似不行,竟然有人調(diào)用了 __libc_csu_init
函數(shù),而這個函數(shù)引用了 _init
。這兩個符號都在哪里呢?
$ readelf -s /usr/lib/crt1.o | grep __libc_csu_init
18: 00000000 0 NOTYPE GLOBAL DEFAULT UND __libc_csu_init
$ readelf -s /usr/lib/crti.o | grep _init
17: 00000000 0 FUNC GLOBAL DEFAULT 5 _init
竟然是 crt1.o
調(diào)用了 __libc_csu_init
函數(shù),而該函數(shù)卻引用了我們沒有鏈接的 crti.o
文件中定義的 _init
符號。這樣的話不鏈接 crti.o
和 crtn.o
文件就不成了羅?不對吧,要不干脆不用 crt1.o
算了,看看 gcc
額外鏈接進去的最后一個文件 crt1.o
到底干了個啥子?
$ ld -m elf_i386 -dynamic-linker /lib/ld-linux.so.2 -o \
test test.o myprintf.o -L/usr/lib/ -lc
ld: warning: cannot find entry symbol _start; defaulting to 00000000080481a4
這樣卻說沒有找到入口符號 _start
,難道 crt1.o
中定義了這個符號?不過它給默認設置了一個地址,只是個警告,說明 test
已經(jīng)生成,不管怎樣先運行看看再說。
$ ./test
hello, world!
Segmentation fault
貌似程序運行完了,不過結(jié)束時冒出個段錯誤?可能是程序結(jié)束時有問題,用 gdb
調(diào)試看看:
$ gcc -g -c test.c myprintf.c #產(chǎn)生目標代碼, 非交叉編譯,不指定-m也可鏈接,所以下面可去掉-m
$ ld -dynamic-linker /lib/ld-linux.so.2 -o test \
test.o myprintf.o -L/usr/lib -lc
ld: warning: cannot find entry symbol _start; defaulting to 00000000080481d8
$ ./test
hello, world!
Segmentation fault
$ gdb -q ./test
(gdb) l
1 #include "test.h"
2
3 int main()
4 {
5 myprintf();
6 return 0;
7 }
(gdb) break 7 #在程序的末尾設置一個斷點
Breakpoint 1 at 0x80481bf: file test.c, line 7.
(gdb) r #程序都快結(jié)束了都沒問題,怎么會到最后出個問題呢?
Starting program: /mnt/hda8/Temp/c/program/test
hello, world!
Breakpoint 1, main () at test.c:7
7 }
(gdb) n #單步執(zhí)行看看,怎么下面一條指令是0x00000001,肯定是程序退出以后出了問題
0x00000001 in ?? ()
(gdb) n #誒,當然找不到邊了,都跑到0x00000001了
Cannot find bound
更多建議: