幾乎每個人會去編寫一個程序,接著編譯,然后運行該程序并查看您辛勤編碼的成果 。 嘴周看到程序正常運行起來會感覺很棒! 但是,要使這些所有工作順利進行,我們還要感謝其他人。那就是您的編譯器(當然,假設(shè)您使用的是編譯語言,而不是解釋性語言),它在幕后會做很多工作。
在本文中,我將嘗試向您展示如何將您編寫的源代碼轉(zhuǎn)換為計算機可以實際運行的代碼, 我在這里選擇Linux作為我的主機,并選擇C作為編程語言,不用糾結(jié)語言,這里的概念一通百通,可以應(yīng)用于許多編譯語言。
注意: 如果要按照本文中的說明進行操作,則必須確保在本地計算機上安裝了gcc
,elfutils
讓我們從一個簡單的C程序開始,看看編譯器如何轉(zhuǎn)換它
該程序創(chuàng)建兩個變量,將它們加起來并在屏幕上打印結(jié)果。很簡單吧?
但是,讓我們看看這個看似簡單的程序必須經(jīng)過什么才能最終在您的系統(tǒng)上執(zhí)行。
編譯器通常具有以下五個步驟(最后一步是操作系統(tǒng)的一部分)-
讓我們詳細介紹每個步驟。
第一步是預(yù)處理步驟,由預(yù)處理器完成。預(yù)處理程序的工作是處理代碼中存在的所有預(yù)處理程序指令。 這些指令以#
開頭。 但是在處理它們之前,它首先從代碼中刪除了所有注釋,因為這些注釋僅提高人類易讀性。 然后,它找到所有的#
命令,并執(zhí)行命令所"說"的內(nèi)容。
在上面的代碼中,我們僅使用了#include
指令,該指令只是對處理器說,可以復(fù)制stdio.h
文件并將其粘貼到當前位置的該文件中。
您可以通過將-E
標志傳遞給gcc
編譯器來查看預(yù)處理器的輸出
gcc -E sample.c
您將獲得類似以下內(nèi)容的信息
令人困惑的是,第二步也稱為編譯。編譯器從預(yù)處理器獲取輸出,并負責執(zhí)行以下重要任務(wù)。
- 將輸出傳遞給詞法分析器,以識別文件中存在的各種標記。 令牌只是程序中存在的文字,例如
int
,return
,void
,0
等。 詞法分析器還將令牌的類型與每個令牌相關(guān)聯(lián),無論令牌是字符串文字,整數(shù),浮點數(shù),if令牌等。 - 將詞法分析器的輸出傳遞給語法分析器,以檢查程序是否以滿足程序所用語言的語法規(guī)則的方式編寫。例如,在分析此行代碼時,它將引發(fā)語法錯誤
b = a + ;
- 將語法分析器的輸出傳遞給語義分析器,該語義分析器將檢查程序是否滿足語言的語義,例如類型檢查和變量在首次使用之前就已聲明,等等
- 如果程序在語法上是正確的,則將源代碼轉(zhuǎn)換為指定目標體系結(jié)構(gòu)的匯編指令。 默認情況下,它會為其運行的計算機生成程序集。 但是假設(shè)您正在為嵌入式系統(tǒng)構(gòu)建程序,那么您可以傳遞目標計算機的體系結(jié)構(gòu),
gcc
將為該計算機生成程序集
要查看此階段的輸出,請將-S
標志傳遞給gcc
編譯器。
gcc -S sample.c
根據(jù)您的環(huán)境,您將獲得類似以下的內(nèi)容
如果您不懂匯編語言,乍一看,一切都會讓人感到恐懼,但還不錯。與通常的高級語言代碼相比,理解匯編代碼要花更多的時間,但是如果有足夠的時間,您肯定可以閱讀。
讓我們看看這個文件包含什么。
所有以.
開頭的行都是匯編程序指令。.file
表示源文件的名稱,可用于調(diào)試目的。我們的源代碼%d\n
中的字符串文字現(xiàn)在位于.rodata
節(jié)中(ro
表示只讀),因為它是只讀字符串。 編譯器將此字符串命名為LC0
,以便以后在代碼中引用它。 每當您看到以.L
開頭的標簽時,即表示這些標簽在當前文件本地,而其他文件不可見。
.globl
聲明main
是一個全局符號,這意味著可以從其他文件中調(diào)用main
。 .type
聲明main
是一個函數(shù)。 然后進行主要功能的組裝。 您可以忽略以cfi
開頭的指令。 它們用于在異常情況下展開調(diào)用堆棧。 我們將在本文中忽略它們,但是您可以在此處了解更多信息。
現(xiàn)在,讓我們嘗試了解主功能的反匯編。
- 11行:您必須知道,在調(diào)用函數(shù)時,會為該函數(shù)創(chuàng)建一個新的堆棧框架。 為了使之成為可能,我們需要某種方法來知道新函數(shù)返回時調(diào)用方函數(shù)框架指針的開始。 這就是為什么我們將存儲在
rbp
寄存器中的當前幀指針推入堆棧的原因。 - 14行:將當前的堆棧指針移至基本指針。 這成為我們當前的功能框架指針。 圖1示出了推入rbp寄存器之前的狀態(tài),圖2示出了推入前一幀指針并將堆棧指針移至當前幀指針之后的狀態(tài)。
- 16行:我們的程序中有3個局部變量,所有類型均為int。 在我的機器上,每個int占用4個字節(jié),因此我們在堆棧上需要12個字節(jié)的空間來保存我們的局部變量。 我們?yōu)槎褩I系木植孔兞縿?chuàng)建空間的方式是將堆棧指針遞減我們局部變量所需的字節(jié)數(shù)。 遞減,因為堆棧從較高的地址增長到較低的地址。 但是在這里您看到我們遞減的是16,而不是12。原因是,空間是在16個字節(jié)的塊中分配的。 因此,即使您有1個局部變量,也會在堆棧上分配16個字節(jié)的空間。 出于某些架構(gòu)上的性能原因而執(zhí)行此操作。 請參閱圖3,以查看堆棧現(xiàn)在的布局。
- 17-22行: 這段代碼非常簡單。 編譯器已將插槽rbp-12用作變量a的存儲空間,將rbp-8用作b的存儲空間,并將rbp-4用作c的存儲空間。 它將值1和2分別移動到變量a和b的地址。 為了準備加法,它將b值移至edx寄存器,將a寄存器的值移至eax寄存器。 相加的結(jié)果存儲在eax寄存器中,該寄存器隨后被傳送到c變量的地址。
- 23-27行:然后,我們準備進行
printf
調(diào)用。 首先,將c變量的值移至esi
寄存器。 然后,將字符串常量%d\n
的地址移至edi
寄存器。 現(xiàn)在,esi
和edi
寄存器保存我們的printf
調(diào)用的參數(shù)。edi
持有第一個參數(shù),而esi
持有第二個參數(shù)。 然后,我們調(diào)用printf
函數(shù)來打印格式為整數(shù)值的變量c
的值。 這里要注意的是,此時未定義printf
符號。 我們將在本文稍后看到如何解決這個printf
符號。 -
.size
告知主要功能的大?。ㄒ宰止?jié)為單位)。.-main
是一個表達式,其中。 符號表示當前行的地址。 因此,該表達式的值等于主函數(shù)的行的地址-current_address_
,從而為我們提供了主函數(shù)的大小(以字節(jié)為單位)。 -
.ident
只是告訴匯編器在.comment
部分添加以下行。.note.GNU-stack
用于告知該程序的堆棧是否可執(zhí)行。 通常,此偽指令的值為空字符串,這表明堆棧不可執(zhí)行。
現(xiàn)在,我們的程序是以匯編語言編寫的,但仍然是處理器無法理解的語言。 我們必須將匯編語言轉(zhuǎn)換為機器語言,并且該工作由匯編器完成。 匯編器獲取您的匯編文件并生成一個目標文件,該文件是一個二進制文件,其中包含您程序的機器指令。
讓我們將程序集文件轉(zhuǎn)換為目標文件,以查看實際過程。 要獲取程序的目標文件,請將c
標志傳遞給gcc
編譯器。
gcc -c sample.c
您將得到一個擴展名為.o的目標文件。 由于這是一個二進制文件,因此您將無法在常規(guī)文本編輯器中將其打開以查看其內(nèi)容。 但是我們有可用的工具來找出那些目標文件中的內(nèi)容。
目標文件可能具有許多不同的文件格式。 我們將特別關(guān)注一種在Linux
上使用的ELF文件格式。
ELF文件包含以下信息-
- ELF標頭
- 程序頭表
- 節(jié)標題表
- 上表引用的其他一些數(shù)據(jù)
ELF
標頭包含有關(guān)目標文件的一些元信息,例如文件的類型,生成二進制文件的機器,版本,標頭的大小等。要查看標頭,只需將-h
標志傳遞給eu-readelf
實用程序。
從上面的清單中可以看出,該文件沒有任何程序標題,這很好。 程序頭僅存在于可執(zhí)行文件和共享庫中。 在下一步中鏈接文件時,我們將看到程序頭。
但是我們確實有13個部分。 讓我們看看這些部分是什么。 使用-S
標志。
您無需了解以上所有內(nèi)容。 但是從本質(zhì)上講,它為每個section列出了各種信息,例如section的名稱,section的大小以及section距文件開頭的偏移量。 我們使用的重要部分如下:
- 文字部分包含我們的機器代碼
-
rodata
部分包含我們程序中的只讀數(shù)據(jù)。它可能是您在程序中使用的常量或字符串文字。這里只包含%d\n
數(shù)據(jù)部分包含我們程序的初始化數(shù)據(jù)。這是空的,因為我們沒有任何初始化數(shù)據(jù) -
bss
部分類似于data部分,但包含我們程序的未初始化數(shù)據(jù)。未初始化的數(shù)據(jù)可以是聲明為int arr [100]的數(shù)組,該數(shù)組將成為本節(jié)的一部分。關(guān)于bss部分需要注意的一點是,與其他部分根據(jù)其內(nèi)容占用空間不同,bss部分僅包含該部分的大小,而沒有其他內(nèi)容。原因是在加載時,所需要做的只是在本節(jié)中需要分配的字節(jié)數(shù)。這樣,我們減小了最終可執(zhí)行文件的大小 -
strtab
部分列出了程序中包含的所有字符串 -
symtab
節(jié)是符號表。它包含了我們程序的所有符號(變量名和函數(shù)名)。 -
rela.text
部分是重定位部分。稍后再詳細介紹。
您也可以查看這些部分的內(nèi)容,只需將相應(yīng)的部分編號傳遞給eu-readelf
程序即可。 您也可以使用objdump
工具。 它還可以為您提供某些部分的分解。
讓我們更詳細地討論rela.text
部分。 記住我們在程序中使用的printf
函數(shù)。 現(xiàn)在,printf
是我們自己尚未定義的東西,它是C庫的一部分。 通常,當您編譯C程序時,編譯器將以某種方式編譯它們,以使您調(diào)用的C函數(shù)不會與可執(zhí)行文件捆綁在一起,從而減小了最終可執(zhí)行文件的大小。 取而代之的是,表由所有這些符號組成,稱為重定位表,該表隨后由裝入程序中的某些內(nèi)容填充。 稍后我們將討論有關(guān)加載器部分的更多信息,但是現(xiàn)在,重要的是,如果您查看rela.text
部分,您會在此處找到列出的printf
符號。 讓我們在這里確認一次。
您可以忽略第二個重定位部分
.rela.eh_frame
。 它與異常處理有關(guān),在這里我們對它沒有太大興趣。 讓我們在這里看到第一部分。 在那里,我們可以看到兩個條目,其中之一是我們的printf
符號。 該條目的意思是,此文件中使用了一個符號,其名稱為printf
,但尚未定義,該符號位于此文件中距.text
節(jié)開始的偏移量0x31
處。 現(xiàn)在,在.text
部分中檢查偏移量0x31
處的內(nèi)容。
在這里您可以看到偏移量為
0x30
的調(diào)用指令。 e8
代表調(diào)用指令的操作碼,后跟從偏移量0x31
到0x34
的4個字節(jié),應(yīng)該與我們現(xiàn)在沒有的printf
函數(shù)實際地址相對應(yīng),所以它們僅為00。 (稍后,我們將看到該位置實際上并不保存printf
地址,而是使用稱為plt的表間接調(diào)用該地址。稍后我們將介紹這一部分)
到目前為止,我們所做的所有工作都在一個源文件上進行。 但實際上,這種情況很少見。 在實際的生產(chǎn)代碼中,您有數(shù)十萬個源代碼文件,您需要編譯和創(chuàng)建可執(zhí)行文件。 現(xiàn)在,在這種情況下,我們將如何比較到目前為止的步驟?
好吧,所有步驟都將保持不變。 所有源代碼文件將分別進行預(yù)處理,編譯,組裝,最后我們將獲得單獨的目標代碼文件。
現(xiàn)在,每個源代碼文件都不會孤立地編寫。 它們必須具有某些函數(shù),這些全局變量必須在某個文件中定義,并在其他文件的不同位置使用。
鏈接器的工作是收集所有目標文件,遍歷每個目標文件并跟蹤每個文件定義的符號以及使用的符號。 它可以在每個目標文件的符號表中找到所有這些信息。 收集了所有這些信息之后,鏈接器將創(chuàng)建一個目標文件,將每個目標文件中的所有部分組合到相應(yīng)的部分中,并重新放置所有可以解析的符號。
在我們的例子中,我們沒有源文件的集合,只有一個文件,但是由于我們使用C庫中的printf函數(shù),因此我們的源文件將與C庫動態(tài)鏈接。 現(xiàn)在,我們鏈接程序并進一步調(diào)查輸出。
gcc sample.c
在這里我將不做詳細介紹,因為它也是我們上面看到的ELF文件,只有一些新的部分。這里要注意的一件事是,當我們看到從匯編程序獲得的目標文件時,所看到的地址是相對的。但是,在鏈接了所有文件之后,我們幾乎知道了所有內(nèi)容的去向,因此,如果您檢查這些階段的輸出,則它也包含絕對地址。
在此階段,鏈接器已識別出程序中正在使用的所有符號,使用這些符號的人以及定義這些符號的人。鏈接程序僅將符號定義的地址映射到符號的用法。但是在完成所有這些操作之后,此時仍然存在一些尚未解析的符號,其中之一就是我們的printf符號。通常,這些符號既可以是外部定義的變量,也可以是外部定義的函數(shù)。鏈接器還會創(chuàng)建一個重定位表,該重定位表與匯編程序創(chuàng)建的重定位表相同,其中的條目仍未解析。
此時,您應(yīng)該知道一件事。您從其他庫中使用的功能和數(shù)據(jù)可以進行靜態(tài)鏈接或動態(tài)鏈接。靜態(tài)鏈接意味著將這些庫中的函數(shù)和數(shù)據(jù)復(fù)制并粘貼到可執(zhí)行文件中。而如果您進行動態(tài)鏈接,則不會將這些功能和數(shù)據(jù)復(fù)制到可執(zhí)行文件中,從而減小了最終的可執(zhí)行文件大小。
為了使libray具有動態(tài)鏈接的功能,該庫必須是共享庫(so文件)。通常,許多程序使用的公共庫是共享庫,其中之一就是我們的libc庫。 libc被許多程序使用,如果每個程序開始靜態(tài)鏈接到它,那么在任何時候,同一代碼的副本將占據(jù)內(nèi)存中的大量空間。具有動態(tài)鏈接可以解決此問題,并且在任何時候,只有l(wèi)ibc的一個副本會占用內(nèi)存中的空間,并且所有程序都將從該共享庫中引用。
為了使動態(tài)鏈接成為可能,鏈接器還會創(chuàng)建兩個在匯編器生成的目標代碼中不存在的節(jié)。 這些是.plt
(過程鏈接表)和.got
(全局偏移表)部分。 我們將在加載可執(zhí)行文件時介紹這些部分,因為這些部分在實際加載可執(zhí)行文件時會很有用。
現(xiàn)在是時候?qū)嶋H運行我們的可執(zhí)行文件了。
當您在GUI
中單擊文件或從命令行運行該文件時,將間接調(diào)用execev
系統(tǒng)調(diào)用。 正是這個系統(tǒng)調(diào)用,內(nèi)核在其中開始將可執(zhí)行文件加載到內(nèi)存中的工作。
記住上面的程序頭表。 這是非常有用的地方。
內(nèi)核如何知道在文件中的哪里找到該表?好了,可以在ELF標頭中找到該信息,該標頭始終從文件的偏移量0開始。完成此操作后,內(nèi)核將查找所有類型為LOAD的條目,并將它們加載到進程的內(nèi)存空間中。
從上面的清單中可以看到,有兩個類型為LOAD的條目。您還可以查看每個細分中包含哪些部分。
現(xiàn)代操作系統(tǒng)和處理器根據(jù)頁面來管理內(nèi)存。您的計算機內(nèi)存分為固定大小的塊,當任何進程要求一些內(nèi)存時,操作系統(tǒng)都會為該進程分配一定數(shù)量的頁面。除了有效管理內(nèi)存的好處外,這還具有提供安全性的好處。操作系統(tǒng)和內(nèi)核可以為每個頁面設(shè)置保護位。保護位指定特定頁面是只讀頁面,可以寫入頁面還是可以執(zhí)行頁面。保護位設(shè)為“只讀”的頁面無法修改,因此可以防止有意或無意地修改數(shù)據(jù)。
只讀頁面還有一個好處,即同一程序的多個運行進程可以共享同一頁面。由于頁面是只讀的,因此任何正在運行的進程都不能修改這些頁面,因此,每個進程都可以正常工作。
要設(shè)置這些保護位,我們必須以某種方式告訴內(nèi)核,哪些頁面必須標記為只讀,哪些頁面可以寫入和執(zhí)行。這些信息存儲在上面每個條目的標志中。
注意第一個LOAD條目。它標記為R和E,這意味著可以讀取和執(zhí)行這些段,但是不能對其進行修改,如果您向下看并看到這些段中的哪些部分,則可以在其中看到兩個熟悉的部分,.text和。 rodata。因此,我們的代碼和只讀數(shù)據(jù)只能被讀取和執(zhí)行,而不能被修改,這應(yīng)該發(fā)生。
同樣,第二個LOAD條目包含已初始化和未初始化的數(shù)據(jù)GOT表(稍后會詳細介紹),它們被標記為RW,因此可以讀寫,但無法執(zhí)行。
加載這些段并設(shè)置它們的權(quán)限后,內(nèi)核會檢查是否存在.interp段。在靜態(tài)鏈接的可執(zhí)行文件中,不需要此段,因為該可執(zhí)行文件包含它所需的所有代碼,但是對于動態(tài)鏈接的可執(zhí)行文件,此段很重要。該段包含.interp節(jié),其中包含動態(tài)鏈接器的路徑。 (您可以通過將-static標志傳遞給gcc編譯器并檢查生成的可執(zhí)行文件中的頭表來檢查靜態(tài)鏈接的可執(zhí)行文件中是否沒有.interp段。)
在我們的例子中,它將找到一個,并指向/lib64/ld-linux-x86-64.so.2路徑中的動態(tài)鏈接器。與我們的可執(zhí)行文件類似,內(nèi)核將通過讀取標頭,查找其段并將其加載到當前程序的內(nèi)存空間中來開始加載這些共享庫。在不需要所有這些的靜態(tài)鏈接的可執(zhí)行文件中,內(nèi)核將控制權(quán)交給我們的程序,這里內(nèi)核將控制權(quán)交給了動態(tài)鏈接器,并將主函數(shù)的地址壓入堆棧,以便在動態(tài)鏈接器之后完成工作,它知道將控制權(quán)移交給哪里。
現(xiàn)在,我們應(yīng)該了解已經(jīng)跳過太長時間的兩個表,過程鏈接表和全局偏移表,因為它們與動態(tài)鏈接器的功能密切相關(guān)。
程序中可能需要兩種類型的重定位。變量重定位和函數(shù)重定位。對于外部定義的變量,我們將該條目包括在GOT表中,而外部定義的函數(shù)將這些條目包括在兩個表中。因此,從本質(zhì)上講,GOT表具有所有外部定義變量和函數(shù)的條目,而PLT表僅具有函數(shù)的條目。下面的示例將清楚我們有兩個函數(shù)條目的原因。
讓我們以printf函數(shù)為例,看看這些表是如何工作的。 在我們的主要功能中,我們來看一下printf函數(shù)的調(diào)用說明。
400556: e8 a5 fe ff ff callq 0x400400
該調(diào)用指令正在調(diào)用.plt
部分的地址。 讓我們看看那里是什么。
對于每個外部定義的函數(shù),我們在plt部分中都有一個條目,并且所有外觀都相同,并且除第一個條目外,都有三條指令。 這是一個特殊的條目,我們將在以后使用。
在那里,我們找到了跳轉(zhuǎn)到地址0x601018
包含的值的信息。 這些地址是GOT
表中的一個條目。 讓我們看看這些地址的內(nèi)容。
這就是魔術(shù)發(fā)生的地方。除了第一次調(diào)用printf函數(shù)外,此地址處的值將是C庫中printf函數(shù)的實際地址,我們只需跳轉(zhuǎn)到該位置即可。但是第一次,其他事情發(fā)生了。
首次調(diào)用printf函數(shù)時,此位置的值是printf函數(shù)的plt條目中下一條指令的地址。從上面的清單中可以看到,它是以小字節(jié)序格式存儲的400406。在plt條目中的此位置,我們有一個push指令,該指令將0壓入堆棧。每個plt條目都有相同的推送指令,但它們推送的編號不同。 0表示重定位表中printf符號的偏移量。然后,在推入指令之后跟隨跳轉(zhuǎn)指令,該跳轉(zhuǎn)指令跳轉(zhuǎn)到第一個plt條目中的第一個指令。
從上面記住,當我告訴您第一個條目很特殊時。這是因為在這里調(diào)用動態(tài)鏈接器來解析外部符號并重新定位它們。為此,我們跳轉(zhuǎn)到got表中地址601010中包含的地址。這些地址應(yīng)包含用于處理重定位的動態(tài)鏈接程序例程的地址?,F(xiàn)在,這些條目用0填充,但是當程序?qū)嶋H運行且內(nèi)核調(diào)用動態(tài)鏈接器時,鏈接器將填充此地址。
調(diào)用例程時,鏈接器將從外部共享對象中解析更早推送的符號(在本例中為0),并將符號的正確地址放入get表中。因此,從現(xiàn)在開始,當調(diào)用printf函數(shù)時,我們不必查閱鏈接器,我們可以直接從plt跳轉(zhuǎn)到C庫中的printf函數(shù)。
此過程稱為延遲加載。一個程序可能包含許多外部符號,但它可能不會在該程序的一次運行中調(diào)用它們。因此,符號解析被推遲到實際使用,這為我們節(jié)省了一些程序啟動時間。
從上面的討論中可以看到,我們不必修改plt部分,而只需修改gott部分。這就是為什么plt節(jié)位于第一個LOAD段中并標記為只讀,而gett節(jié)位于第二個LOAD段中并標記為Write。
這就是動態(tài)鏈接器的工作方式。我已經(jīng)跳過了很多細節(jié),但是如果您有興趣了解更多詳細信息,那么可以查看這篇文章。
讓我們回到程序加載中。 我們已經(jīng)完成了大部分工作。 內(nèi)核已經(jīng)加載了所有可加載的段,已經(jīng)調(diào)用了動態(tài)鏈接器。 剩下的就是調(diào)用我們的主要功能。 鏈接器完成后就完成了該工作。 當它調(diào)用我們的main函數(shù)時,我們在終端中獲得以下輸出-
3
感謝您閱讀我的文章。 如果您喜歡我的文章或?qū)ξ矣腥魏纹渌ㄗh,請在下面的評論部分中告訴我。 而且,請隨時分享:)