源文件需要經過編譯才能生成可執行文件。在windows下進行開發時,只需要單擊幾個按鈕即可編譯,集成開發環境已經將各種編譯工具的使用封裝好了。linux下也有很多優秀的的集成開發工具,但是更多的時候是直接使用編譯工具:即使使用集成開發工具,也需要掌握一些編譯選項。
PC上的編譯工具鏈為gcc、ld、objcopy、objdump等,它們編譯出來的程序在x86平台上運行。要編譯出能在ARM平台上運行的程序,必須使用交叉編譯工具arm-linux-gcc、arm-linux-ld等。
一、arm-linux-gcc
一個c/c++文件要經過預處理、編譯、匯編和鏈接等4步才能編程可執行文件。
- 預處理:c/c+++源文件中,以#開頭的命令統稱為預處理命令,如包含命令#include、宏定義命令#define、條件編譯命令#if、#ifdef等。預處理就是將要包含(include)的文件插入到源文件、將宏定義展開、根據條件編譯命令選擇要使用的代碼,最后將這些代碼輸出到一個.i文件中等待進一步處理。預處理將用到arm-linux-cpp工具。
- 編譯:編譯就是把c/c++代碼(比如上述的.i文件)翻譯成匯編代碼,所用到的工具為ccl。
- 匯編:匯編就是將第二部輸出的匯編代碼翻譯成符合一定格式的機器代碼,在linux系統上一般表現為OBJ目標文件,用到的工具為arm-linux-as。反匯編是將機器代碼轉為匯編代碼,這在調試程序時常常用到。
- 鏈接:鏈接就是將上步生成的OBJ文件和系統庫的OBJ文件、庫文件鏈接起來,最終生成可以在特定平台運行的可執行文件,用到的工具為arm-linux-ld。
注意:一般把前面三個步驟統稱為編譯。
編譯器利用這四個步驟中的一個或多個來處理輸入文件,源文件的后綴名表示源文件所用的語言,后綴名控制着編譯器的默認動作,如下表所示:
后綴名 | 語言種類 | 后期操作 |
.c | c源程序 | 預處理、編譯、匯編 |
.C | c++源程序 | 預處理、編譯、匯編 |
.cc | c++源程序 | 預處理、編譯、匯編 |
.cxx | c++源程序 | 預處理、編譯、匯編 |
.m | objective-c源程序 | 預處理、編譯、匯編 |
.i | 預處理后的c文件 | 編譯、匯編 |
.ii | 預處理后的c++文件 | 編譯、匯編 |
.s | 匯編語言源程序 | 匯編 |
.S | 匯編語言源程序 | 預處理、匯編 |
.h | 預處理器文件 | 通常不出現在命令行上 |
其他后綴名的文件被傳遞給鏈接器,通常包括以下兩種:
- .o:目標文件(OBJ文件)
- .a:歸檔庫文件
在編譯過程中,除非使用了-c、-S或-E選項,否則最后的步驟總是鏈接。在鏈接階段、所有對應於源程序的.o文件、-l選項指定的庫文件、無法識別的文件名(包括指定的.o目標文件和.a庫文件)按命令行中的順序傳遞給鏈接器。
以一個簡單的"Hello,world!" c程序為例,在/work/hardware目錄下創建hello.c文件,代碼如下:
#include <stdio.h>
int main(int argc,char *argv[]) { printf("Hello World!\n"); return 0; }
使用arm-linux-gcc,只需要一個命令就可以生成可執行文件hello,它包含了以上4個步驟:
arm-linux-gcc -o hello hello.c
如果想查看編譯的細節,加上-v選項:
arm-linux-gcc -v -o hello hello.c
下面我們介紹一下arm-linux-gcc一些常用的選項。
1.1 總體選項
- -c :只預處理、編譯和匯編源程序,不進行鏈接。編譯器對每一個源程序產生一個目標文件。
- -S : 編譯后即停止,不進行匯編,對於每個輸入的非匯編語言文件,輸出結果是匯編語言文件。
- -E : 預處理后即停止,不進行編譯。
- -o file: 確定輸出文件為file。如果沒有用-o選項,缺省的可執行文件的輸出是a.out,目標文件和匯編文件的輸出對source.suffix分別是source.o和source.s,預處理的C源程序的輸出是標准輸出stdout。
- -v : 顯示具體執行的命令信息。
在/work/hardware/options目錄下,新建如下源文件:
main.c:
#include <stdio.h> #include "sub.h"
int main(int argc, char *argv[]) { int i; printf("Min fun!\n"); sub_fun(); return 0; }
sub.h:
void sub_fun();
sub.c:
#include <stdio.h>
void sub_fun() { printf("Sub fun!\n"); }
arm-linux-gcc、arm-linux-ld等工具與gcc、ld等工具的使用方法相似,很多選項是一樣的,主要區別是一個編譯出來的程序是運行在ARM上,一個是運行在PC機上。這里為了演示這些命令的效果,使用gcc、ld等工具進行編譯鏈接,使用上面介紹的選項進行編譯,命令如下:
gcc -c -o main.o main.c gcc -c -o sub.o sub.c gcc -o test main.o sub.o
其中main.o、sub.o是經過了預處理、編譯、匯編后生成的OBJ文件,它們還沒有被鏈接成可執行文件:最后一步將它們鏈接成可執行test,可以直接運行一下命令:
./test
現在試試其他選項,一下命令生成的main.s是main.c的匯編語言文件:
gcc -S -o main.s main.c
以下命令對main.c進行預處理,並將得到的結果打印出來。里面擴展了所有包含的文件、所有定義的宏,在編寫程序時,有時候查找某個宏定義是非常繁瑣的事,可以使用-dM-E選項來查看,命令如下:
gcc -E main.c
1.2 警告選項
- Wall 選項基本打開所有需要注意的警告信息,比如沒有指定類型的聲明、在聲明之前就使用函數、局部變量除了聲明就沒再使用等。
1.3 調試選項
-g : 產生一張用於調試和排錯的擴展符號表。-g選項使程序可以用GNU的調試程序GDB進行調試。優化和調試通常不兼容,同時使用-g和-O(-O2)選項經常會使程序產生奇怪的運行結果。所以不要同時使用-g和-O(-O2)選項
1.4 優化選項
- O或-O1: 對於大函數、優化編譯的過程將占用較長時間和相當大的內存。不使用-O選項的目的是減少編譯的開銷,使編譯結果能夠調試、語句是獨立的。不使用-O或-O1選項時,只有聲明了register的變量才分配使用寄存器。使用了-O或-O1選項時,編譯器會師徒減少目標碼的大小和執行時間。
-O2: 多優化一些,除了涉及空間和速度交換的優化選項,執行幾乎所有的優化工作。例如不進行循環展開(loop unrolling)和函數內嵌(inlining)。和-O選項相比,這個選項既增加了編譯時間,也提高了生成代碼的運行效果。在一般應用中,經常使用該選項。
-O3: 優化的更多,除了打開-O所做的一切,它還打開了-finline-function選項。
-O0: 不優化。
如果指定了多個-O選項,不管帶不帶數字、生效的是最后一個選項。
1.5 鏈接器選項
-lname :在連接時使用函數庫libname.a,連接程序在-Ldir選項指定的目錄下和/lib,/usr/lib目錄下尋找該庫文件。在沒有使用-static選項時,如果發現共享函數庫libname.so,則使用libname.so進行動態連接。
-static : 禁止與共享函數庫連接。
-shared :盡量與共享函數庫連接。
二、arm-linux-ld
我們對每個C或者匯編文件進行單獨編譯,但是不去鏈接,生成很多.o 的文件,這些.o文件首先是分散的,我們首先要考慮的如何組合起來;其次,這些.o文件存在相互調用的關系;再者,我們最后生成的bin文件是要在硬件中運行的,每一部分放在什么地址都要有仔細的說明。arm-linux-ld就是用於將多個目標文件、庫文件鏈接成可執行文件。
-T : 可以直接使用它來指定代碼段、數據段、bss段的起始地址,也可以用來指定一個鏈接腳本、在鏈接腳本中進行更復雜的地址設置。
至於什么是代碼段、數據段、bss段這里簡要介紹一下,具體可以參考嵌入式內存分布詳解(有具體案例):
- 代碼段 (Text segment):存放程序執行代碼的區域,設計在低地址防止堆棧溢后覆蓋現象,嵌入式系統中也就是ROM區;
- 只讀數據段(Read only data):簡稱rodata段,存放常量,字符常量,const常量,據說還存放調試信息;
- 初始化數據段(Initialized data segment):簡稱data段,存放程序中已經初始化全局與初始化靜態變量;
- 未始化數據段(Uninitialized data segment):簡稱bss段,存放程序中未初始化全局與未初始化靜態變量,該區域會在程序載入時由內核清零;
- 棧(Stack):存放局部變量,自動分配與釋放,函數調用時進行內存的分配,調用結束時進行釋放;
- 堆(Heap):動態內存塊,主動分配(malloc/realloc),需要手動釋放(free);可以使用brk和SBR調整大小 ;
內存分布如下圖所示(圖中少畫了只讀數據段):
-T選項只用於鏈接Bootloader、內核等沒有底層軟件支持的軟軟件,鏈接運行於操作系統之上的應用程序時,無需指定-T選項,它們使用默認的方式進行鏈接。
2.1 指定參數
格式如下:
-Ttext startaddr -Tdata startaddr -Tbss startaddr
其中的startaddr分別表示代碼段、數據段和bss段的起始地址,它是一個十六進制數,比如:
arm-linux-ld -Ttext 0x00000000 -g led_on.o -o led_on.elf
它表示代碼段的運行地址為0x0000000,由於沒有定義數據段、bss段的起始地址,它們被依次放在代碼段的后面。
以一個例子說明-Ttext選項作用,在/work/hardware/link目錄下,新建link.s文件:
.text .global _start _start: b step1 step1: ldr pc, =step2 step2: b step2
使用下面的命令編譯、鏈接、反匯編:
arm-linux-gcc -c -o link.o link.s arm-linux-ld -Ttext 0x00000000 link.o -o link.elf_0x00000000 arm-linux-ld -Ttext 0x30000000 link.o -o link.elf_0x30000000 arm-linux-objdump -D link.elf_0x00000000 > link_0x00000000.dis arm-linux-objdump -D link.elf_0x30000000 > link_0x30000000.dis
link.s中用到兩種跳轉方法:b跳轉指令、ldr.直接向pc寄存器賦值指令。
先列出不同-Ttext選項下生成的反匯編文件,再詳細分析由於不同運行地址帶來的差異及影響,這兩個反匯編文件如下:
2.1.1 b step1
先看link.s中第一條指令,b step1,b跳轉指令是一個相對跳轉指令、其機器碼格式如下:
Cond | 1 | 0 | 1 | L | Offset |
其中:
- [31:28] 位是條件碼;
- [27:24]位為1010時,表示b跳轉指令,為1011時表示bl跳轉指令;
- [23:0]表示偏移地址;
使用b或bl跳轉時,下一跳指令的地址是這樣計算的;將指令中24位帶符號的補碼擴展為32位(擴展其符號位);將此32位數左移兩位;將得到的值加到pc寄存器中,即得到跳轉的目標地址。
第一條指令b step1的機器碼為eaffffff。
- 24位帶符號的補碼為0xffffff,將它擴展為32位得到0xffffffff;
- 將此32位數左移兩位得到0xfffffffc,其值就是-4;
- pc的值是當前指令的下兩條指令的地址,加上步驟2得到-4,這恰好是第二條指定step1的地址。
不要被反匯編代碼的b 0x4迷惑,它不是指跳到絕對地址0x4處執行,絕對地址需要按照上述3個步驟計算。可以發現,b跳轉指令依賴於當前pc寄存器的值,這個特性使得b指令的程序不依賴於代碼存儲的位置——即不管這條代碼放在什么位置,b指令都可以跳到正確的位置。這類指令被稱為位置無關碼,使用不同的-Ttext選項,生成的代碼仍然是一樣的。
2.1.2 ldr pc.=step2
再看第二條指令ldr pc.=step2從匯編碼ldr pc,[pc,#00]可以看出,這條指令從內存某個位置讀出數據,並賦值給pc寄存器。
這個位置的地址是當前pc寄存器的值加上偏移值0,其中存放的值依賴於鏈接命令的-Ttext選項,執行這條命令后:
- 對於link_0x00000000.dis,pc=0x00000008;
- 對於link_0x30000000.dis,pc=0x30000008。
執行第三條指令b step2后,程序的運行地址就不同了,分別是0x00000008,0x30000008.
Bootloader、內核等程序剛開始執行時,他們所處的地址通常不等於運行地址。在程序開頭,先使用b、bl、mov等位置無關的指令將代碼從Flash等設備復制到內存的運行地址處,然后在跳到運行地址去執行。
2.2 指定鏈接腳本boot.lds
連接腳本boot.lds如下:
/* s3c2440鏈接腳本 */ OUTPUT_ARCH(arm) ENTRY(_start) SECTIONS { /* 指定程序起始內存地址 */ . = 0x33f80000; __code_start = .; /* 指定當前地址為字邊界對齊 */ . = ALIGN(4); .text : { start.o(.text) init.o(.text) device/dev.o(.text) *(.text) } . = ALIGN(4); /* 只讀數據段,存放常量,字符常量,const常量,據說還存放調試信息 */ .rodata : { *(.rodata*) } . = ALIGN(4); /* 全局的已初始化變量存於.data段 */ .data : { *(.data) } . = ALIGN(4); /* 定義變量,保存.bss段起始地址,可以在匯編代碼中直接使用 */ __bss_start = .; /* 全局的未初始化變量存在於.bss段中 */ .bss : { *(.bss) } __end = .; }
Makefile腳本如下:
boot.elf:$(OBJS) arm-linux-ld -Tboot.lds -o boot.elf $^
在匯編或者C語言中中可以使用鏈接腳本中定義的變量,比如在匯編中引用:
.extern __code_start
比如在C語言中引用:
void copy_nand_to_sdram(void) { /* 要從lds文件中獲得 __code_start, __bss_start 然后從0地址把數據復制到__code_start */ extern int __code_start, __bss_start; volatile u32 *dest = (volatile u32*)&__code_start; volatile u32 *end = (volatile u32*)&__bss_start; ....... }
三、arm-linux-objcopy
被用來復制一個目標文件的內容到另一個文件中,可用於不同源文件的之間的格式轉換。在編譯bootloader、內核時,常用arm-linux-objcopy命令將ELF格式的生成結果轉換為二進制文件,示例:
arm-linux-objcopy –O binary –S file.elf file.bin
常用的選項:
- input-file 、 outflie:輸入和輸出文件,如果沒有outfile,則輸出文件名為輸入文件名。
- -l bfdname或—input-target=bfdname:用來指明源文件的格式,bfdname是BFD庫中描述的標准格式名,如果沒指明,則arm-linux-objcopy自己分析。
- .-O bfdname 輸出的格式,bdfname是BFD庫中描述的標准格式名。
- -F bfdname 同時指明源文件,目的文件的格式。
- -R sectionname 從輸出文件中刪除掉所有名為sectionname的段。
- -S 不從源文件中復制重定位信息和符號信息到目標文件中。
- -g 不從源文件中復制調試符號到目標文件中。
四、arm-linux-objdump
arm-linux-objdump常用來顯示二進制文件信息,常用來查看反匯編代碼,常用選項:
- -b bfdname :指定目標碼格式,這不是必須的,arm-linux-objdump能自動識別許多格式,可以使用arm-linux-objdump -i查看支持的目標碼格式列表;
- --disassemble或-d : 反匯編可執行段;
- --dissassemble-all或-D : 反匯編所有段 ;
- -EB或-EL : 指定字節序
- --file-headers或-f :顯示文件的整體頭部摘要信息;
- --section-headers,--headers或者-h :顯示目標文件中各個段的頭部摘要信息;
- --info 或者-I :顯示支持的目標文件格式和CPU架構;
- --section=name或者-j name:顯示指定section 的信息;
- --architecture=machine或者-m machine: 指定反匯編目標文件時使用的架構 ;
在調試程序時,常常使用arm-linux-objdump命令來得到匯編代碼,如下:
將ELF格式的文件轉換為反匯編文件:
arm-linux-objdump -D file.elf > file.dis
將二進制文件轉換為反匯編文件:
arm-linux-objdump -D -b binary -m arm file.bin > file.dis
五、機器碼
即使使用c/c++或者其他高級語言編程,最后也會被編譯工具轉換為匯編代碼,並最終作為機器碼存儲在內存、硬盤或者其他存儲器上。在調試程序時,經常需要閱讀它的匯編代碼,以下面的匯編代碼為例:
4bc: e3a0244e mov r2, #1308622848; #0x4e000000 4c0: e3a0344e mov r3, #1308622848; #0x4e000000 4c4: e5933000 ldr r3, r3, [r3]
4bc、4c0、4c4是這些代碼的運行地址,就是說運行前,這些指令必須位於內存中的這些地址上;e3a0244e 、e3a0344e 、e5933000是機器碼。
運行地址、機器碼都以16進制表示。CPU用到的、內存中保存的都是機器碼,下是這幾條指令在內存中的示意圖:
...... | |
0x4bc | 0xe3a0244e |
0x4c0 | 0xe3a0344e |
0x4c4 | 0xe5933000 |
...... |
"mov 21, #1308622848"、"mov r3,#1308622848"、"ldr r3,[r3]"是這幾個機器碼的匯編代碼──所謂匯編代碼僅僅是為了方便我們人類讀、寫而引入的,機器碼和匯編代碼之間也僅僅是簡單的轉換關系。
參考CPU的數據手冊可知,ARM的數據處理指令格式為:
以機器碼0xe3a0244e為例:
- [31:28] = 0b1110, 表示這條指令無條件執行;
- [25] = 0b1, 表示 Operand2 是一個立即數;
- [24:21] = 0b1101, 表示這是 MOV 指令, 即 Rd : = Op2;
- [20] = 0b0, 表示這條指令執行時不影響狀態位;
- [15:12] = 0b0010, 表示 Rd 就是 r2;
- [11:0] = 0x44e, 這是一個立即數;
立即數占據機器碼中的低12位表示:最低8位的值稱為immed_8,高4位稱為rotate_imm。立即數的數值計算方法為:<immediate>=immed_8循環右移(2*rotate_imm)。對於"[11:0] =0x44e",其中immed_8=0x4e,rotate_imm=0x4,所以此立即數等於0x4e000000。
綜上所述,機器碼0xe3a0244e的匯編代碼為:
mov r2, #0x4e000000
即:
mov r2, #1308622848
上面的0x4e000000和1308622848是一樣的,之所以強調這點,是因為很多初學者問這樣的問題:"計算機中怎么以 16 進制保存數據?以 16 進制、 10 進制保存數據有什么區別?"。
這類問題與如下問題相似:桌子上有12個蘋果,吃了一個,請問現在還有幾個?你可以回答11 個、0xb個、十一個、eleven個、拾壹個。所謂16進制、10進制、8進制、二進制,都僅僅是對同一個數據的不同表達形式而已,這些不同的表達形式也僅僅是為了方便我們人類(又說了這個詞一遍)讀寫而已,它們所表示的數值及它在計算機中的保存方式是完全一樣的。
參考文章:
【2】嵌入式Linux應用開發完全手冊
【3】arm-linux-ld