「大咖專欄」編譯過程簡介

隨著國內第一本RISC-V中文書籍《手把手教你設計CPU——RISC-V處理器篇》 正式上市,越來越多的愛好者開始使用開源的蜂鳥E203 RISC-V處理核,很多初學者留言詢問有關RISC-V工具鏈使用的問題,因此本公眾號將開始陸續發表若干篇有關RISC-V軟件工具鏈使用的文章,包括:

RISC-V嵌入式開發準備篇1:編譯過程簡介

RISC-V嵌入式開發準備篇2:嵌入式開發的特點介紹

RISC-V嵌入式開發入門篇1:RISC-V GCC工具鏈的介紹

RISC-V嵌入式開發入門篇2:RISC-V彙編語言程序設計

RISC-V嵌入式開發上手篇:基於HBird-E-SDK平臺的軟件開發與運行

RISC-V嵌入式開發實踐篇:運行開源蜂鳥E200 MCU更多示例程序

RISC-V嵌入式開發新奇篇:基於Windows Eclipse IDE的軟件開發與運行

RISC-V嵌入式開發昇華篇:基於開源蜂鳥E200 MCU移植RTOS

本文為RISC-V嵌入式開發準備篇1:編譯過程簡介。本文的目的是對編譯過程進行簡單的科普與回顧,為後續詳細介紹“RISC-V GCC工具鏈”和“RISC-V彙編語言程序設計”打下基礎。

注:本文力求通俗易懂,主要面向初學者,對編譯過程有所瞭解的讀者可以忽略此文。

1 本文概述

本文將介紹如何將高層的C/C++語言編寫的程序轉換成為處理器能夠執行的二進制代碼的過程,該過程即一般編譯原理書籍所介紹的過程,包括四個步驟:

預處理(Preprocessing)

編譯(Compilation)

彙編(Assembly)

鏈接(Linking)

本文限於篇幅,將不會對各個步驟的原理進行詳解,將僅僅結合Linux自帶的GCC工具鏈對其過程進行簡述。感興趣的讀者可以自行查閱其他資料深入學習編譯原理的相關知識。

注意:

本文為了簡化描述與便於初學者理解,將在Linux操作系統平臺上編譯一個Hello World程序並在此Linux平臺上運行作為示例。而嵌入式開發所使用的交叉編譯的使用方法與本文所述的編譯過程有所差異,本公眾號將在後續發文《嵌入式開發的特點介紹》中對嵌入式系統編譯進行更多介紹。

本文使用的是Linux自帶的GCC工具鏈作為演示,而未涉及到如何使用RISC-V GCC工具鏈,本公眾號將在後續發文《RISC-V GCC工具鏈的介紹》中對RISC-V GCC工具鏈進行更多介紹。

2 GCC工具鏈介紹

2.1 GCC工具鏈概述

通常所說的GCC是GUN Compiler Collection的簡稱,是Linux系統上常用的編譯工具。GCC實質上不是一個單獨的程序,而是多個程序的集合,因此通常稱為GCC工具鏈。工具鏈軟件包括GCC、C運行庫、Binutils、GDB等。

GCC

GCC(GNU C Compiler)是編譯工具。本文所要介紹的將C/C++語言編寫的程序轉換成為處理器能夠執行的二進制代碼的過程即由編譯器完成。有關編譯過程的更多介紹請參見後文。

GCC既支持本地編譯(即在一個平臺上編譯該平臺運行的程序),也支持交叉編譯(即在一個平臺上編譯供另一個平臺運行的程序)。

本文為了簡化描述與便於初學者理解,將在Linux操作系統平臺上編譯一個Hello World程序並在此Linux平臺上運行作為示例,即為一種本地編譯的開發方式。

交叉編譯多用於嵌入式系統的開發,有關交叉編譯,本公眾號將在後續發文《嵌入式開發的特點介紹》中對嵌入式系統交叉編譯進行更多介紹。

C運行庫

由於C運行庫的相關背景知識較多,請參見後文對其單獨進行介紹。

Binutils

由於Binutils的相關信息較多,請參見後文對其單獨進行介紹。

GDB

GDB(GNU Project Debugger)是調試工具,可以用於對程序進行調試。

2.2 Binutils

一組二進制程序處理工具,包括:addr2line、ar、objcopy、objdump、as、ld、ldd、readelf、size等。這一組工具是開發和調試不可缺少的工具,分別簡介如下:

addr2line:用來將程序地址轉換成其所對應的程序源文件及所對應的代碼行,也可以得到所對應的函數。該工具將幫助調試器在調試的過程中定位對應的源代碼位置。

as:主要用於彙編,有關彙編的詳細介紹請參見後文。

ld:主要用於鏈接,有關鏈接的詳細介紹請參見後文。

ar:主要用於創建靜態庫。為了便於初學者理解,在此介紹動態庫與靜態庫的概念:

如果要將多個.o目標文件生成一個庫文件,則存在兩種類型的庫,一種是靜態庫,另一種是動態庫。

在windows中靜態庫是以 .lib 為後綴的文件,共享庫是以 .dll 為後綴的文件。在linux中靜態庫是以.a為後綴的文件,共享庫是以.so為後綴的文件。

靜態庫和動態庫的不同點在於代碼被載入的時刻不同。靜態庫的代碼在編譯過程中已經被載入可執行程序,因此體積較大。共享庫的代碼是在可執行程序運行時才載入內存的,在編譯過程中僅簡單的引用,因此代碼體積較小。在Linux系統中,可以用ldd命令查看一個可執行程序依賴的共享庫。

如果一個系統中存在多個需要同時運行的程序且這些程序之間存在共享庫,那麼採用動態庫的形式將更節省內存。但是對於嵌入式系統,大多數情況下都是整個軟件就是一個可執行程序且不支持動態加載的方式,即以靜態庫為主。

ldd:可以用於查看一個可執行程序依賴的共享庫。

objcopy:將一種對象文件翻譯成另一種格式,譬如將.bin轉換成.elf、或者將.elf轉換成.bin等。

objdump:主要的作用是反彙編。有關反彙編的詳細介紹,請參見後文。

readelf:顯示有關ELF文件的信息,請參見後文瞭解更多信息。

size:列出可執行文件每個部分的尺寸和總尺寸,代碼段、數據段、總大小等,請參見後文瞭解使用size的具體使用實例。

Binutils的每個工具的功能均很強大,本節限於篇幅無法詳細介紹其功能,讀者可以自行查閱資料瞭解其詳情。Binutils還有其他工具,在此不一一贅述,感興趣的讀者可以自行查閱其他資料學習。

2.3 C運行庫

為了解釋C運行庫,需要先回憶一下C語言標準。C語言標準主要由兩部分組成:一部分描述C的語法,另一部分描述C標準庫。C標準庫定義了一組標準頭文件,每個頭文件中包含一些相關的函數、變量、類型聲明和宏定義,譬如常見的printf函數便是一個C標準庫函數,其原型定義在stdio頭文件中。

C語言標準僅僅定義了C標準庫函數原型,並沒有提供實現。因此,C語言編譯器通常需要一個C運行時庫(C Run Time Libray,CRT)的支持。C運行時庫又常簡稱為C運行庫。與C語言類似,C++也定義了自己的標準,同時提供相關支持庫,稱為C++運行時庫。

如上所述,要在一個平臺上支持C語言,不僅要實現C編譯器,還要實現C標準庫,這樣的實現才能完全支持C標準。glibc(GNU C Library)是Linux下面C標準庫的實現,其要點如下:

glibc本身是GNU旗下的C標準庫,後來逐漸成為了Linux的標準C庫。glibc 的主體分佈在Linux系統的/lib與/usr/lib目錄中,包括 libc 標準 C 函式庫、libm數學函式庫等等,都以.so做結尾。

注意:Linux系統下面的標準C庫不僅有這一個,如uclibc、klibc、以及Linux libc,但是glibc使用最為廣泛。而在嵌入式系統中使用較多的C運行庫為Newlib,有關Newlib的詳細介紹將在本公眾號後續發文《嵌入式開發的特點介紹》中進行。

Linux系統通常將libc庫作為操作系統的一部分,它被視為操作系統與用戶程序的接口。譬如:glibc不僅實現標準C語言中的函數,還封裝了操作系統提供的系統服務,即系統調用的封裝。

通常情況,每個特定的系統調用對應了至少一個glibc 封裝的庫函數,如系統提供的打開文件系統調用sys_open對應的是glibc中的open函數;其次,glibc 一個單獨的API可能調用多個系統調用,如glibc提供的 printf 函數就會調用如 sys_open、sys_mmap、sys_write、sys_close等系統調用;另外,多個 glibc API也可能對應同一個系統調用,如glibc下實現的malloc、free 等函數用來分配和釋放內存,都利用了內核的sys_brk的系統調用。

對於C++語言,常用的C++標準庫為libstdc++。注意:通常libstdc++與GCC捆綁在一起的,即安裝gcc的時候會把libstdc++裝上。而glibc並沒有和GCC捆綁於一起,這是因為glibc需要與操作系統內核打交道,因此其與具體的操作系統平臺緊密耦合。而libstdc++雖然提供了c++程序的標準庫,但其並不與內核打交道。對於系統級別的事件,libstdc++會與glibc交互,從而和內核通信。

2.4 GCC命令行選項

GCC有著豐富的命令行選項支持各種不同的功能,本文由於篇幅有限,無法一一贅述,請讀者自行查閱相關資料學習。

對於RISC-V的GCC工具鏈而言,還有其特有的編譯選項,本公眾號將在後續發文《RISC-V GCC工具鏈的介紹》中介紹RISC-V GCC工具鏈的更多詳情。

3 準備工作

3.1 Linux安裝

由於GCC工具鏈主要是在Linux環境中進行使用,因此本文也將以Linux系統作為工作環境。

對於Linux的安裝,準備好自己的電腦環境。如果是個人電腦,推薦如下配置:

使用VMware虛擬機在個人電腦上安裝虛擬的Linux操作系統。

由於Linux操作系統的版本眾多,推薦使用Ubuntu 16.04版本的Linux操作系統。

有關如何安裝VMware以及Ubuntu操作系統本文不做介紹,有關Linux的基本使用本文也不做介紹,請讀者自行查閱資料學習。

3.2 準備Hello World程序

為了能夠演示編譯的整個過程,本節先準備一個C語言編寫的簡單Hello程序作為示例,其源代碼如下所示:​

「大咖專欄」編譯過程簡介

​4 編譯過程

4.1 預處理

預處理的過程主要包括以下過程:

將所有的#define刪除,並且展開所有的宏定義,並且處理所有的條件預編譯指令,比如#if #ifdef #elif #else #endif等。

處理#include預編譯指令,將被包含的文件插入到該預編譯指令的位置。

刪除所有註釋“//”和“/* */”。

添加行號和文件標識,以便編譯時產生調試用的行號及編譯錯誤警告行號。

保留所有的#pragma編譯器指令,後續編譯過程需要使用它們。

使用gcc進行預處理的命令如下:​

「大咖專欄」編譯過程簡介

​hello.i文件可以作為普通文本文件打開進行查看,其代碼片段如下所示:​

「大咖專欄」編譯過程簡介

​4.2 編譯

編譯過程就是對預處理完的文件進行一系列的詞法分析,語法分析,語義分析及優化後生成相應的彙編代碼。

使用gcc進行編譯的命令如下:​

「大咖專欄」編譯過程簡介

​上述命令生成的彙編程序hello.s的代碼片段如下所示,其全部為彙編代碼。​

「大咖專欄」編譯過程簡介

​4.3 彙編

彙編過程調用對彙編代碼進行處理,生成處理器能識別的指令,保存在後綴為.o的目標文件中。由於每一個彙編語句幾乎都對應一條處理器指令,因此,彙編相對於編譯過程比較簡單,通過調用Binutils中的彙編器as根據彙編指令和處理器指令的對照表一一翻譯即可。

當程序由多個源代碼文件構成時,每個文件都要先完成彙編工作,生成.o目標文件後,才能進入下一步的鏈接工作。注意:目標文件已經是最終程序的某一部分了,但是在鏈接之前還不能執行。

使用gcc進行彙編的命令如下:​

「大咖專欄」編譯過程簡介

​注意:hello.o目標文件為ELF(Executable and Linkable Format)格式的可重定向文件,不能以普通文本形式的查看(vim文本編輯器打開看到的是亂碼)。有關ELF文件的更多介紹,請參見後文。

4.4 鏈接

經過彙編以後的目標文件還不能直接運行,為了變成能夠被加載的可執行文件,文件中必須包含固定格式的信息頭,還必須與系統提供的啟動代碼鏈接起來才能正常運行,這些工作都是由鏈接器來完成的。

GCC可以通過調用Binutils中的鏈接器ld來鏈接程序運行需要的所有目標文件,以及所依賴的其它庫文件,最後生成一個ELF格式可執行文件。

如果直接調用Binutils中的ld進行鏈接,命令如下,則會報出錯誤:​

「大咖專欄」編譯過程簡介

​之所以直接用ld進行鏈接會報錯是因為僅僅依靠一個hello.o目標文件還無法鏈接成為一個完整的可執行文件,需要明確的指明其需要的各種依賴庫和引導程序以及鏈接腳本,此過程在嵌入式軟件開發時是必不可少的。而在Linux系統中,可以直接使用gcc命令執行編譯直至鏈接的過程,gcc會自動將所需的依賴庫以及引導程序鏈接在一起成為Linux系統可以加載的ELF格式可執行文件。使用gcc進行編譯直至鏈接的命令如下:​

「大咖專欄」編譯過程簡介

​注意:hello可執行文件為ELF(Executable and Linkable Format)格式的可執行文件,不能以普通文本形式的查看(vim文本編輯器打開看到的是亂碼)。

在前文介紹了動態庫與靜態庫的差別,與之對應的,鏈接也分為靜態鏈接和動態鏈接,其要點如下:

靜態鏈接是指在編譯階段直接把靜態庫加入到可執行文件中去,這樣可執行文件會比較大。鏈接器將函數的代碼從其所在地(不同的目標文件或靜態鏈接庫中)拷貝到最終的可執行程序中。為創建可執行文件,鏈接器必須要完成的主要任務是:符號解析(把目標文件中符號的定義和引用聯繫起來)和重定位(把符號定義和內存地址對應起來然後修改所有對符號的引用)。

而動態鏈接則是指鏈接階段僅僅只加入一些描述信息,而程序執行時再從系統中把相應動態庫加載到內存中去。

在Linux系統中,gcc編譯鏈接時的動態庫搜索路徑的順序通常為:首先從gcc命令的參數-L指定的路徑尋找;再從環境變量LIBRARY_PATH指定的路徑尋址;再從默認路徑/lib、/usr/lib、/usr/local/lib尋找。

在Linux系統中,執行二進制文件時的動態庫搜索路徑的順序通常為:首先搜索編譯目標代碼時指定的動態庫搜索路徑;再從環境變量LD_LIBRARY_PATH指定的路徑尋址;再從配置文件/etc/ld.so.conf中指定的動態庫搜索路徑;再從默認路徑/lib、/usr/lib尋找。

在Linux系統中,可以用ldd命令查看一個可執行程序依賴的共享庫。

由於鏈接動態庫和靜態庫的路徑可能有重合,所以如果在路徑中有同名的靜態庫文件和動態庫文件,比如libtest.a和libtest.so,gcc鏈接時默認優先選擇動態庫,會鏈接libtest.so,如果要讓gcc選擇鏈接libtest.a則可以指定gcc選項-static,該選項會強制使用靜態庫進行鏈接。以本節的Hello World為例:

如果使用命令“gcc hello.c -o hello”則會使用動態庫進行鏈接,生成的ELF可執行文件的大小(使用Binutils的size命令查看)和鏈接的動態庫(使用Binutils的ldd命令查看)如下所示:​

「大咖專欄」編譯過程簡介

​如果使用命令“gcc -static hello.c -o hello”則會使用靜態庫進行鏈接,生成的ELF可執行文件的大小(使用Binutils的size命令查看)和鏈接的動態庫(使用Binutils的ldd命令查看)如下所示:​

「大咖專欄」編譯過程簡介

​鏈接器鏈接後生成的最終文件為ELF格式可執行文件,一個ELF可執行文件通常被鏈接為不同的段,常見的段譬如.text、.data、.rodata、.bss等段。有關ELF文件和常見段的更多介紹,請參見後文。

4.5 一步到位的編譯

從功能上分,預處理、編譯、彙編、鏈接是四個不同的階段,但GCC的實際操作上,它可以把這四個步驟合併為一個步驟來執行。如下例所示:​

「大咖專欄」編譯過程簡介

​注意:

一個程序無論有一個源文件還是多個源文件,所有被編譯和鏈接的源文件中必須有且僅有一個main函數。

但如果僅僅是把源文件編譯成目標文件,因為不會進行鏈接,所以main函數不是必需的。

5 分析ELF文件

5.1 ELF文件介紹

在介紹ELF文件之前,首先將其與另一種常見的二進制文件格式bin進行對比:

binary文件,其中只有機器碼。

elf文件除了含有機器碼之外還有其它信息,如:段加載地址,運行入口地址,數據段等。

ELF全稱Executable and Linkable Format,可執行鏈接格式。ELF文件格式主要三種:

可重定向(Relocatable)文件:

文件保存著代碼和適當的數據,用來和其他的目標文件一起來創建一個可執行文件或者是一個共享目標文件。

可執行(Executable)文件:

文件保存著一個用來執行的程序(例如bash,gcc等)。

共享(Shared)目標文件(Linux下後綴為.so的文件):

即所謂共享庫。

5.2 ELF文件的段

ELF文件格式如圖1中所示,位於ELF Header和Section Header Table之間的都是段(Section)。一個典型的ELF文件包含下面幾個段:

.text:已編譯程序的指令代碼段。

.rodata:ro代表read only,即只讀數據(譬如常數const)。

.data:已初始化的C程序全局變量和靜態局部變量。

注意:C程序普通局部變量在運行時被保存在堆棧中,既不出現在.data段中,也不出現在.bss段中。此外,如果變量被初始化值為0,也可能會放到bss段。

.bss:未初始化的C程序全局變量和靜態局部變量。

注意:目標文件格式區分初始化和未初始化變量是為了空間效率,在ELF文件中.bss段不佔據實際的存儲器空間,它僅僅是一個佔位符。

.debug:調試符號表,調試器用此段的信息幫助調試。

上述僅講解了最常見的節,ELF文件還包含很多其他類型的節,本文在此不做贅述,請感興趣的讀者自行查閱其他資料瞭解學習。​

「大咖專欄」編譯過程簡介

​圖1 ELF格式

5.3 查看ELF文件

可以使用Binutils中readelf來查看ELF文件的信息,可以通過readelf --help來查看readelf的選項:​

「大咖專欄」編譯過程簡介

​以本文Hello World示例,使用readelf -S查看其各個section的信息如下:​

「大咖專欄」編譯過程簡介

​5.4 反彙編

由於ELF文件無法被當做普通文本文件打開,如果希望直接查看一個ELF文件包含的指令和數據,需要使用反彙編的方法。反彙編是用於調試和定位處理器問題時最常用的手段。

可以使用Binutils中objdump來對ELF文件進行反彙編,可以通過objdump --help來查看其選項:​

「大咖專欄」編譯過程簡介

​以本文Hello World示例,使用objdump -D對其進行反彙編如下:​

「大咖專欄」編譯過程簡介

​使用objdump -S將其反彙編並且將其C語言源代碼混合顯示出來:​

「大咖專欄」編譯過程簡介

​6 嵌入式系統編譯的特殊性

為了易於讀者理解,本文以一個Hello World程序為例講解了在Linux環境中的編譯過程以幫助初學者入門,但是瞭解這些基礎背景知識對於嵌入式開發還遠遠不夠。

對於嵌入式開發,嵌入式系統的編譯有其特殊性,譬如:

嵌入式系統需要使用交叉編譯與遠程調試的方法進行開發。

需要自己定義引導程序。

需要注意減少代碼尺寸。

需要移植printf從而使得嵌入式系統也能夠打印輸入。

使用Newlib作為C運行庫。

每個特定的嵌入式系統都需要配套的板級支持包。

為了易於讀者理解,本文使用的是Linux自帶的GCC工具鏈,其並不能反映嵌入式開發的特點。本公眾號將在後續發文《嵌入式開發的特點介紹》《RISC-V GCC工具鏈的介紹》中介紹“嵌入式開發特點”和“RISC-V GCC工具鏈”的更多詳情。

7 總結

編譯原理是一門博大精深的學科,雖然大多數的用戶只是將編譯器作為一門工具使用而無需關注其內部原理,但是適當的瞭解編譯的過程對於開發大有裨益,尤其是對於嵌入式軟件開發而言,更需要了解編譯與鏈接的基本過程。

本文為了簡化描述與便於初學者理解,僅僅以在Linux操作系統平臺上使用其自帶的GCC編譯一個Hello World程序作為示例。本文雖面向的是RISC-V嵌入式開發,其使用的RISC-V工具鏈交叉編譯使用方法與本文所述的編譯過程有所差異,但是其原理和使用方法大致相同,因此也可以作為初學者的學習參考。

「大咖專欄」編譯過程簡介

《手把手教你設計CPU——RISC-V處理器篇》

胡振波 著

本書是一本介紹通用CPU設計的入門書,以通俗的語言系統介紹了CPU和RISC-V架構,力求為讀者揭開CPU設計的神秘面紗,打開計算機體系結構的大門。

本書共分為四部分。第一部分是CPU與RISC-V的綜述,幫助初學者對CPU和RISC-V快速地建立起認識。第二部分講解如何使用Verilog設計CPU,使讀者掌握處理器核的設計精髓。第三部分主要介紹蜂鳥E203配套的SoC和軟件平臺,使讀者實現蜂鳥E203 RISC-V處理器在FPGA原型平臺上的運行。第四部分是附錄,介紹了RISC-V指令集架構,輔以作者加入的背景知識解讀和註解,以便於讀者理解。 ​


分享到:


相關文章: