可執行文件(ELF)格式的理解

轉自:https://www.cnblogs.com/xmphoenix/archive/2011/10/23/2221879.html

ELF(Executable and Linking Format)是一種對象文件的格式,用于定義不同類型的對象文件(Object files)中都放了什么東西、以及都以什么樣的格式去放這些東西。它自最早在 System V 系統上出現后,被 xNIX 世界所廣泛接受,作為缺省的二進制文件格式來使用。可以說,ELF是構成眾多xNIX系統的基礎之一,所以作為嵌入式Linux系統乃至內核驅動程序開發人員,你最好熟悉并掌握它。

其實,關于ELF這個主題,網絡上已經有相當多的文章存在,但是其介紹的內容比較分散,使得初學者不太容易從中得到一個系統性的認識。為了幫助大家學習,我這里打算寫一系列連貫的文章來介紹ELF以及相關的應用。這是這個系列中的第一篇文章,主要是通過不同工具的使用來熟悉ELF文件的內部結構以及相關的基本概念。后面的文章,我們會介紹很多高級的概念和應用,比方動態鏈接和加載,動態庫的開發,C語言Main函數是被誰以及如何被調用的,ELF格式在內核中的支持,Linux內核中對ELF section的擴展使用等等。

好的,開始我們的第一篇文章。在詳細進入正題之前,先給大家介紹一點ELF文件格式的參考資料。在ELF格式出來之后,TISC(Tool Interface Standard Committee)委員會定義了一套ELF標準。你可以從這里(http://refspecs.freestandards.org/elf/)找到詳細的標準文檔。TISC委員會前后出了兩個版本,v1.1和v1.2。兩個版本內容上差不多,但就可讀性上來講,我還是推薦你讀 v1.2的。因為在v1.2版本中,TISC重新組織原本在v1.1版本中的內容,將它們分成為三個部分(books):

a) Book I

介紹了通用的適用于所有32位架構處理器的ELF相關內容

b) Book II

介紹了處理器特定的ELF相關內容,這里是以Intel x86 架構處理器作為例子介紹

c) Book III

介紹了操作系統特定的ELF相關內容,這里是以運行在x86上面的 UNIX System V.4 作為例子介紹

值得一說的是,雖然TISC是以x86為例子介紹ELF規范的,但是如果你是想知道非x86下面的ELF實現情況,那也可以在http://refspecs.freestandards.org/elf/中找到特定處理器相關的Supplment文檔。比方ARM相關的,或者MIPS相關的等等。另外,相比較UNIX系統的另外一個分支BSD Unix,Linux系統更靠近 System V 系統。所以關于操作系統特定的ELF內容,你可以直接參考v1.2標準中的內容。

這里多說些廢話:別忘了 Linus 在實現Linux的第一個版本的時候,就是看了介紹Unix內部細節的書:《The of the Unix Operating System》,得到很多啟發。這本書對應的操作系統是System V 的第二個Release。這本書介紹了操作系統的很多設計觀念,并且行文簡單易懂。所以雖然現在的Linux也吸取了其他很多Unix變種的設計理念,但是如果你想研究學習Linux內核,那還是以看這本書作為開始為好。這本書也是我在接觸Linux內核之前所看的第一本介紹操作系統的書,所以我極力向大家推薦。(在學校雖然學過操作系統原理,但學的也是很糟糕最后導致期末考試才四十來分,記憶仿佛還在昨天:))

好了,還是回來開始我們第一篇ELF主題相關的文章吧。這篇文章主要是通過使用不同的工具來分析對象文件,來使你掌握ELF文件的基本格式,以及了解相關的基本概念。你在讀這篇文章的時候,希望你在電腦上已經打開了那個 v1.2 版本的ELF規范,并對照著文章內容看規范里的文字。

首先,你需要知道的是所謂對象文件(Object files)有三個種類:

  1. 可重定位的對象文件(Relocatable file)

這是由匯編器匯編生成的 .o 文件。后面的鏈接器(link editor)拿一個或一些 Relocatable object files 作為輸入,經鏈接處理后,生成一個可執行的對象文件 (Executable file) 或者一個可被共享的對象文件(Shared object file)。我們可以使用 ar 工具將眾多的 .o Relocatable object files 歸檔(archive)成 .a 靜態庫文件。如何產生 Relocatable file,你應該很熟悉了,請參見我們相關的基本概念文章和JulWiki。另外,可以預先告訴大家的是我們的內核可加載模塊 .ko 文件也是 Relocatable object file。

  1. 可執行的對象文件(Executable file)

這我們見的多了。文本編輯器vi、調式用的工具gdb、播放mp3歌曲的軟件mplayer等等都是Executable object file。你應該已經知道,在我們的 Linux 系統里面,存在兩種可執行的東西。除了這里說的 Executable object file,另外一種就是可執行的腳本(如shell腳本)。注意這些腳本不是 Executable object file,它們只是文本文件,但是執行這些腳本所用的解釋器就是 Executable object file,比如 bash shell 程序。

  1. 可被共享的對象文件(Shared object file)

這些就是所謂的動態庫文件,也即 .so 文件。如果拿前面的靜態庫來生成可執行程序,那每個生成的可執行程序中都會有一份庫代碼的拷貝。如果在磁盤中存儲這些可執行程序,那就會占用額外的磁盤空間;另外如果拿它們放到Linux系統上一起運行,也會浪費掉寶貴的物理內存。如果將靜態庫換成動態庫,那么這些問題都不會出現。動態庫在發揮作用的過程中,必須經過兩個步驟:

a) 鏈接編輯器(link editor)拿它和其他Relocatable object file以及其他shared object file作為輸入,經鏈接處理后,生存另外的 shared object file 或者 executable file。

b) 在運行時,動態鏈接器(dynamic linker)拿它和一個Executable file以及另外一些 Shared object file 來一起處理,在Linux系統里面創建一個進程映像。

以上所提到的 link editor 以及 dynamic linker 是什么東西,你可以參考我們基本概念中的相關文章。對于什么是編譯器,匯編器等你應該也已經知道,在這里只是使用他們而不再對他們進行詳細介紹。為了下面的敘述方便,你可以下載test.tar.gz包,解壓縮后使用"make"進行編譯。編譯完成后,會在目錄中生成一系列的ELF對象文件,更多描述見里面的 README 文件。我們下面的論述都基于這些產生的對象文件。

make所產生的文件,包括 sub.o/sum.o/test.o/libsub.so/test 等等都是ELF對象文件。至于要知道它們都屬于上面三類中的哪一種,我們可以使用 file 命令來查看:

[yihect@juliantec test]$ file sum.o sub.o test.o libsub.so test
sum.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
sub.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
test.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
libsub.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), not stripped
test: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), for GNU/Linux 2.2.5, dynamically linked (uses shared libs), not stripped

結果很清楚的告訴我們他們都屬于哪一個類別。比方 sum.o 是應用在x86架構上的可重定位文件。這個結果也間接的告訴我們,x86是小端模式(LSB)的32位結構。那對于 file 命令來說,它又能如何知道這些信息?答案是在ELF對象文件的最前面有一個ELF文件頭,里面記載了所適用的處理器、對象文件類型等各種信息。在TISCv1.2的規范中,用下面的圖描述了ELF對象文件的基本組成,其中ELF文件頭赫然在目。

[圖片上傳失敗...(image-f47526-1587041043467)]

等等,為什么會有左右兩個很類似的圖來說明ELF的組成格式?這是因為ELF格式需要使用在兩種場合:

a) 組成不同的可重定位文件,以參與可執行文件或者可被共享的對象文件的鏈接構建;

b) 組成可執行文件或者可被共享的對象文件,以在運行時內存中進程映像的構建。

所以,基本上,圖中左邊的部分表示的是可重定位對象文件的格式;而右邊部分表示的則是可執行文件以及可被共享的對象文件的格式。正如TISCv1.2規范中所闡述的那樣,ELF文件頭被固定地放在不同類對象文件的最前面。至于它里面的內容,除了file命令所顯示出來的那些之外,更重要的是包含另外一些數據,用于描述ELF文件中ELF文件頭之外的內容。如果你的系統中安裝有 GNU binutils 包,那我們可以使用其中的 readelf 工具來讀出整個ELF文件頭的內容,比如:

<pre style="margin-top: 0px; margin-bottom: 0px; white-space: pre-wrap; overflow-wrap: break-word; color: rgb(0, 0, 0); font-size: 13px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">[yihect@juliantec test]$ readelf -h ./sum.o
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 184 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 40 (bytes)
Number of section headers: 9
Section header string table index: 6
</pre>

這個輸出結果能反映出很多東西。那如何來看這個結果中的內容,我們還是就著TISCv1.2規范來。在實際寫代碼支持ELF格式對象文件格式的時候,我們都會定義許多C語言的結構來表示ELF格式的各個相關內容,比方這里的ELF文件頭,你就可以在TISCv1.2規范中找到這樣的結構定義(注意我們研究的是針對x86架構的ELF,所以我們只考慮32位版本,而不考慮其他如64位之類的):

[圖片上傳失敗...(image-3b536-1587041043464)]

這個結構里面出現了多種數據類型,同樣可以在規范中找到相關說明:

[圖片上傳失敗...(image-996900-1587041043464)]

在我們以后一系列文章中,我們會著重拿實際的程序代碼來分析,介時你會在頭文件中找到同樣的定義。但是這里,我們只討論規范中的定義,暫不考慮任何程序代碼。在ELF頭中,字段e_machine和e_type指明了這是針對x86架構的可重定位文件,最前面有個長度為16字節的字段中有一個字節表示了它適用于32bits機器,而不是64位的。除了這些之外,另外ELF頭還告訴了我們其他一些特別重要的信息,分別是:

a) 這個sum.o的進入點是0x0(e_entry),這表面Relocatable objects不會有程序進入點。所謂程序進入點是指當程序真正執行起來的時候,其第一條要運行的指令的運行時地址。因為Relocatable objects file只是供再鏈接而已,所以它不存在進入點。而可執行文件test和動態庫.so都存在所謂的進入點,你可以用 readelf -h 看看。后面我們的文章中會介紹可執行文件的e_entry指向C庫中的_start,而動態庫.so中的進入點指向 call_gmon_start。這些后面再說,這里先不深入討論。

b) 這個sum.o文件包含有9個sections,但卻沒有segments(Number of program headers為0)。

那什么是所謂 sections 呢?可以說,sections 是在ELF文件里頭,用以裝載內容數據的最小容器。在ELF文件里面,每一個 sections 內都裝載了性質屬性都一樣的內容,比方:

  1. .text section 里裝載了可執行代碼;

  2. .data section 里面裝載了被初始化的數據;

  3. .bss section 里面裝載了未被初始化的數據;

  4. 以 .rec 打頭的 sections 里面裝載了重定位條目;

  5. .symtab 或者 .dynsym section 里面裝載了符號信息;

  6. .strtab 或者 .dynstr section 里面裝載了字符串信息;

  7. 其他還有為滿足不同目的所設置的section,比方滿足調試的目的、滿足動態鏈接與加載的目的等等。

一個ELF文件中到底有哪些具體的 sections,由包含在這個ELF文件中的 section head table(SHT)決定。在SHT中,針對每一個section,都設置有一個條目,用來描述對應的這個section,其內容主要包括該 section 的名稱、類型、大小以及在整個ELF文件中的字節偏移位置等等。我們也可以在TISCv1.2規范中找到SHT表中條目的C結構定義:

[圖片上傳失敗...(image-8c64c4-1587041043464)]

我們可以像下面那樣來使用 readelf 工具來查看可重定位對象文件 sum.o 的SHT表內容:[yihect@juliantec test]$ readelf -S ./sum.o
There are 9 section headers, starting at offset 0xb8:

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 00000b 00 AX 0 0 4
[ 2] .data PROGBITS 00000000 000040 000004 00 WA 0 0 4
[ 3] .bss NOBITS 00000000 000044 000000 00 WA 0 0 4
[ 4] .note.GNU-stack PROGBITS 00000000 000044 000000 00 0 0 1
[ 5] .comment PROGBITS 00000000 000044 00002d 00 0 0 1
[ 6] .shstrtab STRTAB 00000000 000071 000045 00 0 0 1
[ 7] .symtab SYMTAB 00000000 000220 0000a0 10 8 7 4
[ 8] .strtab STRTAB 00000000 0002c0 00001d 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)

這個結果顯示了 sum.o 中包含的所有9個sections。因為sum.o僅僅是參與link editor鏈接的可重定位文件,而不參與最后進程映像的構建,所以Addr(sh_addr)為0。后面你會看到可執行文件以及動態庫文件中大部分sections的這一字段都是有某些取值的。Off(sh_offset)表示了該section離開文件頭部位置的距離。Size(sh_size)表示section的字節大小。ES(sh_entsize)只對某些形式的sections 有意義。比方符號表 .symtab section,其內部包含了一個表格,表格的每一個條目都是特定長度的,那這里的這個字段就表示條目的長度10。Al(sh_addralign)是地址對齊要求。另外剩下的兩列Lk和Inf,對應著條目結構中的字段sh_link和字段sh_info。它們中記錄的是section head table 中的條目索引,這就意味著,從這兩個字段出發,可以找到對應的另外兩個 section,其具體的含義解釋依據不同種類的 section 而不同,后面會介紹。

注意上面結果中的 Flg ,表示的是對應section的相關標志。比方.text section 里面存儲的是代碼,所以就是只讀的(X);.data和.bss里面存放的都是可寫的(W)數據(非在堆棧中定義的數據),只不過前者存的是初始化過的數據,比方程序中定義的賦過初值的全局變量等;而后者里面存儲的是未經過初始化的數據。因為未經過初始化就意味著不確定這些數據剛開始的時候會有些什么樣的值,所以針對對象文件來說,它就沒必要為了存儲這些數據而在文件內多留出一塊空間,因此.bss section的大小總是為0。后面會看到,當可執行程序被執行的時候,動態連接器會在內存中開辟一定大小的空間來存放這些未初始化的數據,里面的內存單元都被初始化成0。可執行程序文件中雖然沒有長度非0的 .bss section,但卻記錄有在程序運行時,需要開辟多大的空間來容納這些未初始化的數據。

另外一個標志A說明對應的 section 是Allocable的。所謂 Allocable 的section,是指在運行時,進程(process)需要使用它們,所以它們被加載器加載到內存中去。

而與此相反,存在一些non-Allocable 的sections,它們只是被鏈接器、調試器或者其他類似工具所使用的,而并非參與進程的運行中去的那些 section。比方后面要介紹的字符串表section .strtab,符號表 .symtab section等等。當運行最后的可執行程序時,加載器會加載那些 Allocable 的部分,而 non-Allocable 的部分則會被繼續留在可執行文件內。所以,實際上,這些 non-Allocable 的section 都可以被我們用 stip 工具從最后的可執行文件中刪除掉,刪除掉這些sections的可執行文件照樣能夠運行,只不過你沒辦法來進行調試之類的事情罷了。

我們仍然可以使用 readelf -x SecNum 來傾印出不同 section 中的內容。但是,無奈其輸出結果都是機器碼,對我們人來說不具備可讀性。所以我們換用 binutils 包中的另外一個工具 objdump 來看看這些 sections 中到底具有哪些內容,先來看看 .text section 的:[yihect@juliantec test]$ objdump -d -j .text ./sum.o

./sum.o: file format elf32-i386

Disassembly of section .text:

00000000 :
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 8b 45 0c mov 0xc(%ebp),%eax
6: 03 45 08 add 0x8(%ebp),%eax
9: c9 leave
a: c3 ret

objdump 的選項 -d 表示要對由 -j 選擇項指定的 section 內容進行反匯編,也就是由機器碼出發,推導出相應的匯編指令。上面結果顯示在 sum.o 對象文件的 .text 中只是包含了函數 sum_func 的定義。用同樣的方法,我們來看看 sum.o 中 .data section 有什么內容:[yihect@juliantec test]$ objdump -d -j .data ./sum.o

./sum.o: file format elf32-i386

Disassembly of section .data:

00000000 :
0: 17 00 00 00 ....

這個結果顯示在 sum.o 的 .data section 中定義了一個四字節的變量 gv_inited,其值被初始化成 0x00000017,也就是十進制值 23。別忘了,x86架構是使用小端模式的。

我們接下來來看看字符串表section .strtab。你可以選擇使用 readelf -x :

[yihect@juliantec test]$ readelf -x 8 ./sum.o

Hex dump of section '.strtab':
0x00000000 64657469 6e695f76 6700632e 6d757300 .sum.c.gv_inited
0x00000010 00 68630063 6e75665f 6d757300 .sum_func.ch.

上面命令中的 8 是 .strtab section 在SHT表格中的索引值,從上面所查看的SHT內容中可以找到。盡管這個命令的輸出結果不是那么具有可讀性,但我們還是得來說一說如何看這個結果,因為后續文章中將會使用大量的這種命令。上面結果中的十六進制數據部分從右到左看是地址遞增的方向,而字符內容部分從左到右看是地址遞增的方向。所以,在 .strtab section 中,按照地址遞增的方向來看,各字節的內容依次是 0x00、0x73、0x75、0x6d、0x2e ....,也就是字符 、's'、'u'、'm'、'.' ... 等。如果還是看不太明白,你可以使用 hexdump 直接dumping出 .strtab section 開頭(其偏移在文件內0x2c0字節處)的 32 字節數據:

[yihect@juliantec test]$ hexdump -s 0x2c0 -n 32 -c ./sum.o 00002c0 s u m . c g v _ i n i t e d 00002d0 s u m _ f u n c c h 00002dd

.strtab section 中存儲著的都是以字符為分割符的字符串,這些字符串所表示的內容,通常是程序中定義的函數名稱、所定義過的變量名稱等等。。。當對象文件中其他地方需要和一個這樣的字符串相關聯的時候,往往會在對應的地方存儲 .strtab section 中的索引值。比方下面將要介紹的符號表 .symtab section 中,有一個條目是用來描述符號 gv_inited 的,那么在該條目中就會有一個字段(st_name)記錄著字符串 gv_inited 在 .strtab section 中的索引 7 。 .shstrtab 也是字符串表,只不過其中存儲的是 section 的名字,而非所函數或者變量的名稱。

字符串表在真正鏈接和生成進程映像過程中是不需要使用的,但是其對我們調試程序來說就特別有幫助,因為我們人看起來最舒服的還是自然形式的字符串,而非像天書一樣的數字符號。前面使用objdump來反匯編 .text section 的時候,之所以能看到定義了函數 sum_func ,那也是因為存在這個字符串表的原因。當然起關鍵作用的,還是符號表 .symtab section 在其中作為中介,下面我們就來看看符號表。

雖然我們同樣可以使用 readelf -x 來查看符號表(.symtab)section的內容,但是其結果可讀性太差,我們換用 readelf -s 或者 objdump -t 來查看(前者輸出結果更容易看懂):

[yihect@juliantec test]$ readelf -s ./sum.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 sum.c
2: 00000000 0 SECTION LOCAL DEFAULT 1
3: 00000000 0 SECTION LOCAL DEFAULT 2
4: 00000000 0 SECTION LOCAL DEFAULT 3
5: 00000000 0 SECTION LOCAL DEFAULT 4
6: 00000000 0 SECTION LOCAL DEFAULT 5
7: 00000000 4 OBJECT GLOBAL DEFAULT 2 gv_inited
8: 00000000 11 FUNC GLOBAL DEFAULT 1 sum_func
9: 00000001 1 OBJECT GLOBAL DEFAULT COM ch

在符號表內針對每一個符號,都會相應的設置一個條目。在繼續介紹上面的結果之前,我們還是從規范中找出符號表內條目的C結構定義:

[圖片上傳失敗...(image-24235b-1587041043464)]

上面結果中 Type 列顯示出符號的種類。Bind 列定義了符號的綁定類型。種類和綁定類型合并在一起,由結構中 st_info 字段來定義。在ELF格式中,符號類型總共可以有這么幾種:

[圖片上傳失敗...(image-94a63a-1587041043464)]

類型 STT_OBJECT 表示和該符號對應的是一個數據對象,比方程序中定義過的變量、數組等,比方上面的 gv_inited 和 ch;類型 STT_FUNC 表示該符號對應的是函數,比方上面的 sum_func函數。類型 STT_SECTION 表示該符號和一個 section 相關,這種符號用于重定位。關于重定位,我們下文會介紹。

符號的綁定類型表示了這個符號的可見性,是僅本對象文件可見呢,還是全局可見。它的取值主要有三種:STB_LOCA、STB_GLOBAL和STB_WEAK,具體的內容還請參見規范。關于符號,最重要的就是符號的值(st_value)了。依據對象文件的不同類型,符號的值所表示的含義也略有差異:

a) 在可重定位文件中,如果該符號對應的section index(上面的Ndx)為SHN_COMMON,那么符號的值表示的是該數據的對齊要求,比方上面的變量 ch 。

b) 在可重定位文件中,除去上面那條a中定義的符號,對于其他的符號來說,其值表示的是對應 section 內的偏移值。比方 gv_inited 變量定義在 .data section 的最前面,所以其值為0。

c) 在可執行文件或者動態庫中,符號的值表示的是運行時的內存地址。

好,咱們再來介紹重定位。在所產生的對象文件 test.o 中有對函數 sum_func 的引用,這對我們的x386結構來說,其實就是一條call指令。既然 sum_func 是定義在 sum.o 中的,那對 test.o 來說,它就是一個外部引用。所以,匯編器在產生 test.o 的時候,它會產生一個重定位條目。重定位條目中會包含以下幾類東西:

  1. 它會包含一個符號表中一個條目的索引,因為這樣我們才知道它具體是哪個符號需要被重定位的;

  2. 它會包含一個 .text section 中的地址單元的偏移值。原本這個偏移值處的地址單元里面應該存放著 call 指令的操作數。對上面來說,也就是函數 sum_func 的地址,但是目前這個地址匯編器還不知道。

  3. 它還會包含一個tag,以指明該重定位屬于何種類型。

當我們用鏈接器去鏈接這個對象文件的時候,鏈接器會遍歷所有的重定位條目,碰到像 sum_func 這樣的外部引用,它會找到 sum_func 的確切地址,并且把它寫回到上面 call 指令操作數所占用的那個地址單元。像這樣的操作,稱之為重定位操作。link editor 和 dynamic linker 都要完成一些重定位操作,只不過后者的動作更加復雜,因為它是在運行時動態完成的,我們以后的文章會介紹相關的內容。概括一下,所謂重定位操作就是:“匯編的時候產生一個空坐位,上面用紅紙寫著要坐在這個座位上的人的名字,然后連接器在開會前安排那個人坐上去”。

如前面我們說過的,對象文件中的重定位條目,會構成一個個單獨的 section。這些 section 的名字,常會是這樣的形式:".rel.XXX"。其中XXX表示的是這些重定位條目所作用到的section,如 .text section。重定位條目所構成的section需要和另外兩個section產生關聯:符號表section(表示要重定位的是哪一個符號)以及受影響地址單元所在的section。在使用工具來查看重定位section之前,我們先從規范中找出來表示重定位條目的結構定義(有兩種,依處理器架構來定):

[圖片上傳失敗...(image-550a43-1587041043464)]

結構中 r_offset 對于可重定位文件.o來說,就是地址單元的偏移值(前面的b條);另外對可執行文件或者動態庫來說,就是該地址單元的運行時地址。上面 a條中的符號表內索引和c條中的類型,一起構成了結構中的字段 r_info。

重定位過程在計算最終要放到受影響地址單元中的時候,需要加上一個附加的數 addend。當某一種處理器選用 Elf32_Rela 結構的時候,該 addend 就是結構中的 r_addend 字段;否則該 addend 就是原本存儲在受影響地址單元中的原有值。x86架構選用 Elf32_Rel 結構來表示重定位條目。ARM架構也是用這個。

重定位類型意味著如何去修改受影響的地址單元,也就是按照何種方式去計算需要最后放在受影響單元里面的值。具體的重定位類型有哪些,取決與特定的處理器架構,你可以參考相關規范。這種計算方式可以非常的簡單,比如在x386上的 R_386_32 類型,它規定只是將附加數加上符號的值作為所需要的值;該計算方式也可以是非常的復雜,比如老版本ARM平臺上的 R_ARM_PC26。在這篇文章的末尾,我會詳細介紹一種重定位類型:R_386_PC32。至于另外一些重要的重定位類型,如R_386_GOTPC,R_386_PLT32,R_386_GOT32,R_386_GLOB_DAT 以及 R_386_JUMP_SLOT 等。讀者可以先自己研究,也許我們會在后面后面的文章中討論到相關主題時再行介紹。

我們可以使用命令 readelf -r 來查看重定位信息:

[yihect@juliantec test_2]$ readelf -r test.o

Relocation section '.rel.text' at offset 0x464 contains 8 entries:
Offset Info Type Sym.Value Sym. Name
00000042 00000902 R_386_PC32 00000000 sub_func
00000054 00000a02 R_386_PC32 00000000 sum_func
0000005d 00000a02 R_386_PC32 00000000 sum_func
0000007a 00000501 R_386_32 00000000 .rodata
0000007f 00000b02 R_386_PC32 00000000 printf
0000008d 00000c02 R_386_PC32 00000000 double_gv_inited
00000096 00000501 R_386_32 00000000 .rodata
0000009b 00000b02 R_386_PC32 00000000 printf

至此,ELF對象文件格式中的 linking view ,也就是上面組成圖的左邊部分,我們已經介紹完畢。在這里最重要的概念是 section。在可重定位文件里面,section承載了大多數被包含的東西,代碼、數據、符號信息、重定位信息等等。可重定位對象文件里面的這些sections是作為輸入,給鏈接器那去做鏈接用的,所以這些 sections 也經常被稱做輸入 section。

鏈接器在鏈接可執行文件或動態庫的過程中,它會把來自不同可重定位對象文件中的相同名稱的 section 合并起來構成同名的 section。接著,它又會把帶有相同屬性(比方都是只讀并可加載的)的 section 都合并成所謂 segments(段)。segments 作為鏈接器的輸出,常被稱為輸出section。我們開發者可以控制哪些不同.o文件的sections來最后合并構成不同名稱的 segments。如何控制呢,就是通過 linker script 來指定。關于鏈接器腳本,我們這里不予討論。

一個單獨的 segment 通常會包含幾個不同的 sections,比方一個可被加載的、只讀的segment 通常就會包括可執行代碼section .text、只讀的數據section .rodata以及給動態鏈接器使用的符號section .dymsym等等。section 是被鏈接器使用的,但是 segments 是被加載器所使用的。加載器會將所需要的 segment 加載到內存空間中運行。和用 sections header table 來指定一個可重定位文件中到底有哪些 sections 一樣。在一個可執行文件或者動態庫中,也需要有一種信息結構來指出包含有哪些 segments。這種信息結構就是 program header table,如ELF對象文件格式中右邊的 execute view 所示的那樣。

我們可以用 readelf -l 來查看可執行文件的程序頭表,如下所示:

[yihect@juliantec test_2]$ readelf -l ./test

Elf file type is EXEC (Executable file)
Entry point 0x8048464
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 0x0073c 0x0073c R E 0x1000
LOAD 0x00073c 0x0804973c 0x0804973c 0x00110 0x00118 RW 0x1000
DYNAMIC 0x000750 0x08049750 0x08049750 0x000d0 0x000d0 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

結果顯示,在可執行文件 ./test 中,總共有7個 segments。同時,該結果也很明白顯示出了哪些 section 映射到哪一個 segment 當中去。比方在索引為2的那個segment 中,總共有15個 sections 映射進來,其中包括我們前面提到過的 .text section。注意這個segment 有兩個標志: R 和 E。這個表示該segment是可讀的,也可執行的。如果你看到標志中有W,那表示該segment是可寫的。

我們還是來解釋一下上面的結果,希望你能對照著TISCv1.2規范里面的文本來看,我這里也列出程序頭表條目的C結構:

[圖片上傳失敗...(image-fc5407-1587041043464)]

上面類型為PHDR的segment,用來包含程序頭表本身。類型為INTERP的segment只包含一個 section,那就是 .interp。在這個section中,包含了動態鏈接過程中所使用的解釋器路徑和名稱。在Linux里面,這個解釋器實際上就是 /lib/ ,這可以通過下面的 hexdump 看出來:[yihect@juliantec test_2]$ hexdump -s 0x114 -n 32 -C ./test
00000114 2f 6c 69 62 2f 6c 64 2d 6c 69 6e 75 78 2e 73 6f |/lib/ld-linux.so|
00000124 2e 32 00 00 04 00 00 00 10 00 00 00 01 00 00 00 |.2..............|
00000134

為什么會有這樣的一個 segment?這是因為我們寫的應用程序通常都需要使用動態鏈接庫.so,就像 test 程序中所使用的 libsub.so 一樣。我們還是先大致說說程序在linux里面是怎么樣運行起來的吧。當你在 shell 中敲入一個命令要執行時,內核會幫我們創建一個新的進程,它在往這個新進程的進程空間里面加載進可執行程序的代碼段和數據段后,也會加載進動態連接器(在Linux里面通常就是 /lib/ld-linux.so 符號鏈接所指向的那個程序,它本省就是一個動態庫)的代碼段和數據。在這之后,內核將控制傳遞給動態鏈接庫里面的代碼。動態連接器接下來負責加載該命令應用程序所需要使用的各種動態庫。加載完畢,動態連接器才將控制傳遞給應用程序的main函數。如此,你的應用程序才得以運行。

這里說的只是大致的應用程序啟動運行過程,更詳細的,我們會在后續的文章中繼續討論。我們說link editor鏈接的應用程序只是部分鏈接過的應用程序。經常的,在應用程序中,會使用很多定義在動態庫中的函數。最最基礎的比方C函數庫(其本身就是一個動態庫)中定義的函數,每個應用程序總要使用到,就像我們test程序中使用到的 printf 函數。為了使得應用程序能夠正確使用動態庫,動態連接器在加載動態庫后,它還會做更進一步的鏈接,這就是所謂的動態鏈接。為了讓動態連接器能成功的完成動態鏈接過程,在前面運行的link editor需要在應用程序可執行文件中生成數個特殊的 sections,比方 .dynamic、.dynsym、.got和.plt等等。這些內容我們會在后面的文章中進行討論。

我們先回到上面所輸出的文件頭表中。在接下來的數個 segments 中,最重要的是三個 segment:代碼段,數據段和堆棧段。代碼段和堆棧段的 VirtAddr 列的值分別為 0x08048000 和 0x0804973c。這是什么意思呢?這是說對應的段要加載在進程虛擬地址空間中的起始地址。雖然在可執行文件中規定了 text segment和 data segment 的起始地址,但是最終,在內存中的這些段的真正起始地址,卻可能不是這樣的,因為在動態鏈接器加載這些段的時候,需要考慮到頁面對齊的因素。為什么?因為像x86這樣的架構,它給內存單元分配讀寫權限的最小單位是頁(page)而不是字節。也就是說,它能規定從某個頁開始、連續多少頁是只讀的。卻不能規定從某個頁內的哪一個字節開始,連續多少個字節是只讀的。因為x86架構中,一個page大小是4k,所以,動態鏈接器在加載 segment 到虛擬內存中的時候,其真實的起始地址的低12位都是零,也即以 0x1000 對齊。

我們先來看看一個真實的進程中的內存空間信息,拿我們的 test 程序作為例子。在 Linux 系統中,有一個特殊的由內核實現的虛擬文件系統 /proc。內核實現這個文件系統,并將它作為整個Linux系統面向外部世界的一個接口。我們可以通過 /proc 觀察到一個正在運行著的Linux系統的內核數據信息以及各進程相關的信息。所以我們如果要查看某一個進程的內存空間情況,也可以通過它來進行。使用/proc唯一需要注意的是,由于我們的 test 程序很小,所以當我們運行起來之后,它很快就會結束掉,使得我們沒有時間去查看test的進程信息。我們需要想辦法讓它繼續運行,或者最起碼運行直到讓我們能從 /proc 中獲取得到想要的信息后再結束。

我們有多種選擇。最簡單的是,在 test main 程序中插入一個循環,然后在循環中放入 sleep() 的調用,這樣當程序運行到這個循環的時候,就會進入“運行-睡眠-運行-睡眠”循環中。這樣我們就有機會去看它的虛擬內存空間信息。另外一個方法,是使用調試器,如GDB。我們設置一個斷點,然后在調試過程中讓test進程在這個斷點處暫停,這樣我們也有機會獲得地址空間的信息。我們這里就使用這種方法。當然,為了能讓GDB調試我們的 test,我們得在編譯的時候加上"-g"選項。最后我們用下面的命令得到 test 程序對應進程的地址空間信息。

[yihect@juliantec ~]$ cat /proc/pgrep test/maps
00103000-00118000 r-xp 00000000 08:02 544337 /lib/ld-2.3.4.so
00118000-00119000 r--p 00015000 08:02 544337 /lib/ld-2.3.4.so
00119000-0011a000 rw-p 00016000 08:02 544337 /lib/ld-2.3.4.so
0011c000-00240000 r-xp 00000000 08:02 544338 /lib/tls/libc-2.3.4.so
00240000-00241000 r--p 00124000 08:02 544338 /lib/tls/libc-2.3.4.so
00241000-00244000 rw-p 00125000 08:02 544338 /lib/tls/libc-2.3.4.so
00244000-00246000 rw-p 00244000 00:00 0
00b50000-00b51000 r-xp 00000000 08:02 341824 /usr/lib/libsub.so
00b51000-00b52000 rw-p 00000000 08:02 341824 /usr/lib/libsub.so
08048000-08049000 r-xp 00000000 08:05 225162 /home/yihect/test_2/test
08049000-0804a000 rw-p 00000000 08:05 225162 /home/yihect/test_2/test
b7feb000-b7fed000 rw-p b7feb000 00:00 0
b7fff000-b8000000 rw-p b7fff000 00:00 0
bff4c000-c0000000 rw-p bff4c000 00:00 0
ffffe000-fffff000 ---p 00000000 00:00 0

注意,上面命令中的pgre test 是用`括起來的,它不是單引號,而是鍵盤上 Esc 字符下面的那個字符。從這個結果上可以看出,所有的段,其起始地址和結束地址(前面兩列)都是0x1000對齊的。結果中也列出了對應的段是從哪里引過來的,比方動態鏈接器/lib/ld-2.3.4.so、C函數庫和test程序本身。注意看test程序引入的代碼段起始地址是 0x08048000,這和我們 ELF 文件中指定的相同,但是結束地址卻是0x08049000,和文件中指定的不一致(0x08048000+0x0073c=0x0804873c)。這里,其實加載器也把數據segment中開頭一部分也映射進了 text segment 中去;同樣的,進程虛擬內存空間中的 data segment 從 08049000 開始,而可執行文件中指定的是從 0x0804973c 開始。所以加載器也把代碼segment中末尾一部分也映射進了 data segment 中去了。

從程序頭表中我們可以看到一個類型為 GNU_STACK 的segment,這是 stack segment。程序頭表中的這一項,除了 Flg/Align 兩列不為空外, 其他列都為0。這是因為堆棧段在虛擬內存空間中,從哪里開始、占多少字節是由內核說了算的,而不決定于可執行程序。實際上,內核決定把堆棧段放在整個進程地址空間的用戶空間的最上面,所以堆棧段的末尾地址就是 0xc0000000。別忘記在 x86 中,堆棧是從高向低生長的。

好,為了方便你對后續文章的理解,我們在這里討論一種比較簡單的重定位類型 R_386_PC32。前面我們說過重定義的含義,也即在連接階段,根據某種計算方式計算出一個新的值(通常是地址),然后將這個值重新改寫到對象文件或者內存映像中某個section中的某個地址單元中去的這樣一個過程。那所謂重定位類型,就規定了使用何種方式,去計算這個值。既然是計算,那就肯定需要涉及到所要納入計算的變量。實際上,具體有哪些變量參與計算如同如何進行計算一樣也是不固定的,各種重定位類型有自己的規定。

根據規范里面的規定,重定位類型 R_386_PC32 的計算需要有三個變量參與:S,A和P。其計算方式是 S+A-P。根據規范,當R_386_PC32類型的重定位發生在 link editor 鏈接若干個 .o 對象文件從而形成可執行文件的過程中的時候,變量S指代的是被重定位的符號的實際運行時地址,而變量P是重定位所影響到的地址單元的實際運行時地址。在運行于x86架構上的Linux系統中,這兩個地址都是虛擬地址。變量A最簡單,就是重定位所需要的附加數,它是一個常數。別忘了x86架構所使用的重定位條目結構體類型是 Elf32_Rela,所以附加數就存在于受重定位影響的地址單元中。重定位最后將計算得到的值patch到這個地址單元中。

或許,咱們舉一個實際例子來闡述可能對你更有用。在我們的 test 程序中,test.c 的 main 函數中需要調用定義在 sum.o 中的 sum_func 函數,所以link editor 在將 test.o/sum.o 聯結成可執行文件 test 的時候,必須處理一個重定位,這個重定位就是 R_386_PC32 類型的。我們先用 objdump 來查看 test.o 中的 .text section 內容(我只選取了前面一部分):[yihect@juliantec test_2]$ objdump -d -j .text ./test.o

./test.o: file format elf32-i386

Disassembly of section .text:

00000000 <main />:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 ec 18 sub 0x18,%esp 6: 83 e4 f0 and0xfffffff0,%esp
9: b8 00 00 00 00 mov 0x0,%eax e: 83 c0 0f add0xf,%eax
11: 83 c0 0f add 0xf,%eax 14: c1 e8 04 shr0x4,%eax
17: c1 e0 04 shl 0x4,%eax 1a: 29 c4 sub %eax,%esp 1c: c7 45 fc 0a 00 00 00 movl0xa,0xfffffffc(%ebp)
23: c7 45 f8 2d 00 00 00 movl 0x2d,0xfffffff8(%ebp) 2a: c7 45 f4 03 00 00 00 movl0x3,0xfffffff4(%ebp)
31: c7 45 f0 48 00 00 00 movl 0x48,0xfffffff0(%ebp) 38: 83 ec 08 sub0x8,%esp
3b: ff 75 f0 pushl 0xfffffff0(%ebp)
3e: ff 75 f4 pushl 0xfffffff4(%ebp)
41: e8 fc ff ff ff call 42
46: 83 c4 08 add 0x8,%esp 49: 50 push %eax 4a: 83 ec 0c sub0xc,%esp
4d: ff 75 f8 pushl 0xfffffff8(%ebp)
50: ff 75 fc pushl 0xfffffffc(%ebp)
53: e8 fc ff ff ff call 54
58: 83 c4 14 add $0x14,%esp
......

如結果所示,在離開 .text section 開始 0x53 字節的地方,有一條call指令。這條指令是對 sum_func 函數的調用,objdump 將其反匯編成 call 54,這是因為偏移 0x54 字節的地方原本應該放著 sum_func 函數的地址,但現在因為 sum_func 定義在 sum.o 中,所以這個地方就是重定位需要做 patch 的地址單元所在處。我們注意到,這個地址單元的值為 0xfffffffc,也就是十進制的 -4(計算機中數是用補碼表示的)。所以,參與重定位運算的變量A就確定了,即是 -4。

我們在 test.o 中找出影響該地址單元的重定位記錄如下:

[yihect@juliantec test_2]$ readelf -r ./test.o | grep 54
00000054 00000a02 R_386_PC32 00000000 sum_func

果然,如你所見,該條重定位記錄是 R_386_PC32 類型的。前面變量A確定了,那么另外兩個變量S和變量P呢?從正向去計算這兩個變量的值比較麻煩。盡管我們知道,在Linux里面,鏈接可執行程序時所使用的默認的鏈接器腳本將最后可執行程序的 .text segment 起始地址設置在 0x08048000的位置。但是,從這個地址出發,去尋找符號(函數)sub_func 和 上面受重定位影響的地址單元的運行時地址的話,需要經過很多人工計算,所以比較麻煩。

相反的,我們使用objdump工具像下面這樣分析最終鏈接生成的可執行程序 ./test 的 .text segment 段,看看函數 sum_func 和 那個受影響單元的運行時地址到底是多少,這是反向的查看鏈接器的鏈接結果。鏈接器在鏈接的過程中是正向的將正確的地址分配給它們的。

[yihect@juliantec test_2]$ objdump -d -j .text ./test

./test: file format elf32-i386

Disassembly of section .text:

08048498 :
8048498: 31 ed xor %ebp,%ebp
......
08048540 <main />:
......
804858a: 83 ec 0c sub 0xc,%esp 804858d: ff 75 f8 pushl 0xfffffff8(%ebp) 8048590: ff 75 fc pushl 0xfffffffc(%ebp) 8048593: e8 74 00 00 00 call 804860c 8048598: 83 c4 14 add0x14,%esp
804859b: 50 push %eax
......

0804860c :
804860c: 55 push %ebp
804860d: 89 e5 mov %esp,%ebp
804860f: 8b 45 0c mov 0xc(%ebp),%eax
8048612: 03 45 08 add 0x8(%ebp),%
8048615: c9 leave
8048616: c3 ret
8048617: 90 nop

......

從中很容易的就可以看出,鏈接器給函數 sum_func 分配的運行時地址是 0x0804860c,所以變量S的值就是 0x0804860c。那么變量P呢?它表示的是重定位所影響地址單元的運行地址。如果要計算這個地址,我們可以先看看 main 函數的運行時地址,再加上0x54字節的偏移來得到。從上面看出 main 函數的運行時地址為 0x08048540,所以重定位所影響地址單元的運行時地址為 0x08048540+0x54 = 0x08048594。所以重定位計算的最終結果為:

S+A-P = 0x0804860c+(-4)-0x08048594 = 0x00000074

從上面可以看出,鏈接器在鏈接過程中,確實也把這個計算得到的結果存儲到了上面 call 指令操作數所在的地址單元中去了。那么,程序在運行時,是如何憑借這樣一條帶有如此操作數的 call 指令來調用到(或者跳轉到)函數 sum_func 中去的呢?

你看,調用者 main 和被調用者 sum_func 處在同一個text segment中。根據x86架構或者IBM兼容機的匯編習慣,段內轉移或者段內跳轉時使用的尋址方式是PC相對尋址。也就是若要讓程序從一個段內的A處,跳轉到同一段內的B處,那么PC相對尋址會取程序在A處執行時的PC值,再加上某一個偏移值(offset),得到要跳轉的目標地址(B處地址)。那么,對于x86架構來說,由于有規定,PC總是指向下一條要執行的指令,那么當程序執行在call指令的時候,PC指向的是下一條add指令,其值也就是 0x8048598。最后,尋址的時候再加上call指令的操作數0x74作為偏移,計算最終的 sum_func 函數目標地址為 0x8048598+0x74 = 0x804860c。

有點意思吧:),如果能繞出來,那說明我們是真的明白了,其實,繞的過程本身就充滿著趣味性,就看你自己的心態了。說到這里,本文行將結束。本文所介紹的很多內容,可能在某些同學眼中會過于簡單,但是為了體現知識的完整性、同時也為了讓大家先有個基礎以便更容易的看后續的文章,我們還是在這里介紹一下ELF格式的基礎知識。下面一篇關于ELF主題的文章,將詳細介紹動態連接的內在實現。屆時,你將看到大量的實際代碼挖掘。

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,572評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,071評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,409評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,569評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,360評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,895評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,979評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,123評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,643評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,559評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,742評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,250評論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,981評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,363評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,622評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,354評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,707評論 2 370

推薦閱讀更多精彩內容