ARM裸機程式研究 - 編譯和連結
1. Linux下的二進位制可執行檔案。
如果世界很簡單,那麼二進位制可執行檔案也應該很簡單,只包括CPU要執行的指令就可以了。可惜,世界並不簡單……。Linux下的二進位制可執行檔案(以下簡稱可執行檔案),也並不是只包括了指令,還包括了很多其他的資訊,比如,執行需要的資料,重定位資訊,除錯資訊,動態連結資訊,等等。 所有這些資訊都按照一個預定的格式組織在一個可執行檔案裡面。Linux下叫ELF可執行檔案。
舉一個最簡單的例子,假設有下面這個程式:
int main()
{
return 0;
}
這個連“Hello World”都不能列印的程式,自然是什麼都做不了。當然,如果只是把這個檔案儲存為文字檔案,是無論如何也執行不了得。還需要兩個重要的步驟:編譯和連結,才能把它轉換為可執行的ELF格式。
先來看看編譯,也就是把C語言翻譯成機器語言的過程。很簡單,用下面的命令:
gcc -c test.c -o test.o <假設原始檔名為test.c>
-c 引數告訴gcc,我們只需要編譯這個檔案,不需要連線。這樣就會生成一個test.o檔案。這個檔案包含了上面源程式翻譯後的機器指令和其他一些資訊。這個test.o也屬於ELF格式。如何看test.o裡面的內容,可以用objdump命令:
objdump -x test.o
會有類似下面的輸出:
[html] view plain copy
- test.o: file format elf32-i386
- test.o
- architecture: i386, flags 0x00000010:
- HAS_SYMS
- start address 0x00000000
- Sections:
- Idx Name Size VMA LMA File off Algn
- 0 .text 0000000a 00000000 00000000 00000034 2**
- CONTENTS, ALLOC, LOAD, READONLY, CODE
- 1 .data 00000000 00000000 00000000 00000040 2**2
- CONTENTS, ALLOC, LOAD, DATA
- 2 .bss 00000000 00000000 00000000 00000040 2**2
- ALLOC
- 3 .comment 0000002b 00000000 00000000 00000040 2**0
- CONTENTS, READONLY
- 4 .note.GNU-stack 00000000 00000000 00000000 0000006b 2**0
- CONTENTS, READONLY
- SYMBOL TABLE:
- 00000000 l df *ABS* 00000000 test.c
- 00000000 l d .text 00000000 .text
- 00000000 l d .data 00000000 .data
- 00000000 l d .bss 00000000 .bss
- 00000000 l d .note.GNU-stack 00000000 .note.GNU-stack
- 00000000 l d .comment 00000000 .comment
- 00000000 g F .text 0000000a main
test.o 主要包含了檔案頭和節。"節“是ELF檔案的重要組成部分,一個節就是某一型別的資料。objdump的-x引數會打印出test.o中所有的節,也就是上面的"Sections". 其中.text節包含了可執行程式碼,.data節包含了已經初始化的資料,.bss節包含了未初始化資料。其他的節先忽略掉(其實是因為我也瞭解不多⋯⋯)
如果要看看test.o是不是包含原始檔的編譯結果, 可以將其反彙編檢視。使用objdump -d 命令。 預設情況下,該命令只返回目標檔案的可執行部分,在這裡就是.text節。 objdump -d test.o 得到的結果如下:
[plain] view plain copy
- test.o: file format elf32-i386
- Disassembly of section .text:
- 00000000 <main>:
- 0: 55 push %ebp
- 1: 89 e5 mov %esp,%ebp
- 3: b8 00 00 00 00 mov $0x0,%eax
- 8: 5d pop %ebp
- 9: c3 ret
可以看見這裡就是一些棧的操作,沒有做什麼事情。當然,原始碼裡面確實也沒做什麼事情。這個.o檔案還不能執行,還需要經過連結。通常,我們可以用gcc一步完成編譯連結過程,也就是我們最常用的:
gcc test.c -o test
如果再次用objdump -d 反編譯生成的test檔案:
objdump -d test
額……會發現多了一堆東西。這是因為,c程式通常都是連結到c執行庫的。在main函式執行前,c執行庫需要初始化一些東西。這也說明,main()並不是程式的真正入口點。真正的入口點可以用objdump -f 檢視test的檔案頭:
- test: file format elf32-i386
- architecture: i386, flags 0x00000112:
- EXEC_P, HAS_SYMS, D_PAGED
- start address 0x080482e0
start address就是開始執行的入口點, 這個地址對應反彙編中的"_start"符號。
那麼可以讓程式不連結到c執行庫麼?當然可以,可以用ld手工連結:
ld test.o -e main -o test
“-e main”告訴ld連結器用main函式作為入口點。這裡也可以看出,一個程式的入口函式,不一定是main,可以是任意函式。再次反彙編剛生成的可執行檔案,就會發現,已經沒有c執行庫的程式碼了。
可是,如果試著執行剛剛生成的程式,竟然會得到一個段錯誤……這是因為,沒有了c執行庫,main函式返回之後,程式執行到不確定的地方。而如果通過c執行庫呼叫main(),返回後會到c執行庫裡面,會呼叫相關函式來結束程序。
2. 裸機程式的實現
所謂裸機程式,也就是沒有作業系統支援,晶片上電後就可以開始執行的程式,就和微控制器程式一樣。不知道用”裸機程式“這個名稱是否合適,不過也找不到其他的名字了。
裸機程式與上面的ELF可執行檔案有什麼不同,首先很明顯一點,ELF檔案是需要有一個解析器,或者叫裝載器的, 這個裝載器負責解析檔案頭,將其中的節都對映到程序空間,如果有重定位,要先完成重定位,如果有動態連結庫,還要載入動態連結庫,完成種種初始化之後,才跳轉到程式的入口點開始執行程式。而所有這些,都是由OS支援的。而對於一個ARM晶片來說,他可不知道什麼ELF,重定位和動態連結。ARM只知道上電後,暫存器復位到初始值,PC暫存器為0x00000000,也就是從記憶體地址為0的地方開始取指令執行,其它的一概不知道,也不管。
這麼說來,要弄出一個裸機程式,其實也不難,只要我們編譯上面的原始碼,然後想辦法把它載入到記憶體0開始的地方就可以了。事實,也確實是這樣。只是有幾個小問題要先解決掉:
1.從0開始的記憶體從哪來?那個地方為什麼會有記憶體?
2.如何把程式放到記憶體0開始的地方
3.就算是一個簡單的main()函式,也需要棧。誰來負責設定棧?
首先看1,一般ARM晶片都會外接一定數量的ROM和RAM。而從0開始的地址一般都會對映到ROM上,這樣上電後,CPU才能取到指令執行。不過這樣給除錯程式帶來了一點困難,ROM裡面的程式碼不容易修改。如果想反覆修改程式,除錯程式,就不太方便。當然,ARM CPU都還有外接的RAM,不過這些大都是SDRAM。 SDRAM在晶片初始化的時候是還不能用的,需要初始化SDRAM控制器,設定一些初始值才行。
我現在有的開發板是QQ2440,使用的samsung S3C2440的SOC。2440有一個很好的特性,就是可以從NAND啟動。CPU是不能直接訪問NAND儲存器的,需要通過NAND控制器。也就是說,不能把NAND裡面的內容直接對映到CPU的地址空間。為此,2440裡面有一個叫“steppingsone”的地方,其實就是一塊4K 的RAM。當設定從NAND啟動時,上電後,2440裡面的復位邏輯會先從NAND裡面把前4K的內容讀出來,放到這個steppingstone裡面,因為這個RAM是對映到地址0開始的,當CPU開始執行程式的時候,就能夠順利的取到指令。一般這裡面的程式會初始化SDRAM,把剩餘的程式都複製到RAM裡面,然後跳轉的RAM開始執行。不過對於我們的試驗來說,剛開始完全可以在這個4K的steppingstone裡面來完成。
第二個問題,最直接的辦法,就是把程式燒在ROM或NAND裡面,對映到地址為0的地方。不過對於試驗來說,有些不太方便。第二種方法是通過JTAG介面下載,我就是用的這種方法,使用QQ2440自帶的並口小板和openocd,這種方法靈活性最大。還有一種方法,一般開發板自帶的ROM裡面都會有預裝的bootloader。它可以通過串列埠或者USB從PC上下載程式到記憶體指定的地方,然後跳轉過去執行。這種方法也很方便。
第三個問題,因為c程式的最小單位就是函式,函式執行是需要棧的,用來儲存一些區域性變數和儲存返回地址。其實初始化棧只要將棧基址暫存器設定在記憶體中的合適的地方就可以了,只是這點小動作需要用一點點組合語言來完成。
用編輯器建立下面的彙編原始檔檔案:
[plain] view plain copy
- .section .init
- .global _init
- _init:
- ldr sp, =0x00001000
- bl mymain
- loop: b loop
這段程式碼裡面,定義一個名為“.init"得節, 然後實際的指令就兩個,將0x00001000裝入sp暫存器,和跳轉到mymain執行。sp是棧指標,0x00001000剛好是4K,也就是我們將棧設定在了4k的地方,也就是steppingstone的最末尾,因為棧是從記憶體高階向低端增長的。 後面的“b loop"是一個死迴圈,這樣mymain返回的話,就會停在這裡,不至於執行到不確定的地方。
把這個原始檔儲存為init.S,使用ARM交叉編譯器編譯:
arm-linux-as init.S -o init.o
生成的init.o檔案,也可以用arm-linux-objdump 看一下,是不是期望的內容。我們所期望得,就是裡面應該有一個.init節,該節的反彙編程式碼,也就是原始碼裡的3條指令。
有了這段小彙編程式碼來設定最基本的C執行環境,下面就可以用C語言來程式設計了。首先是一段最簡單的,就是點亮qq2440開發板上的4個LED。
[plain] view plain copy- #define GPBCON (*(unsigned long*)0x56000010)
- #define GPBDAT (*(unsigned long*)0x56000014)
- #define GPBUP (*(unsigned long*)0x56000018)
- #define WTCON (*(unsigned long*)0x53000000)
- int mymain()
- {
- WTCON = 0; /* turn off watch dog. */
- unsigned long v = GPBCON;
- v &= 0xFFFc03FF;
- v |= 0x00015400;
- GPBCON = v;
- v = GPBDAT;
- v &= ~0x000001e0;
- GPBDAT = v; /* turn on all LEDs */
- return 0;
- }
關於2440的GPIO控制,可以檢視其資料手冊。這段程式碼用巨集定義了些暫存器的地址,這些地址都可以參考資料手冊。接下來,是mymain函式。首先通過設定WTCON暫存器來關閉看門狗。2440中看門狗在復位後預設是開啟狀態,如果不關閉,晶片在其超時後會自動復位。然後,通過設定GPBCON和GPBDAT暫存器來點亮LED。
將上面的c原始檔儲存為led.c, 用gcc編譯
gcc -c led.c -o led.o
這樣就會得到一個包含編譯後可執行程式碼的led.o檔案,其中的.text節包含的就是二進位制程式碼,可以使用arm-linux-objdump檢視。現在的情況是:我們有個init.o檔案,其中.init節儲存有需要最開始執行的初始程式碼。還有一個led.o檔案,其中.text節儲存的是c原始檔編譯後的可執行二進位制程式碼。而我們需要的,是將init.o中的.init節和led.o中的.text節拿出來拼接在一起,並且保證.init節的程式碼放在最開始。這就需要連結器了。但是預設情況下連結器完成不了這個工作,前面說過,預設情況下,連結器會連結c執行庫,而且會尋找main函式入口點。更甚,在現在的這種情況下,連結器跟本不知道需要連結哪些節,以及如何安排這些節的位置。我們需要通過額外的辦法來指導連結器完成我們需要的工作,這個就是連結指令碼。連結指令碼的文件可以在gnu.org上找到。這裡,我們只需要一個非常簡單的指令碼,如下:
[plain] view plain copy
- SECTIONS
- {
- .text : {*(.init) *(.text)}
- }
這個指令碼中通過SECTIONS命令定義了一個節,節的名字為.text,而節的內容,就是冒號後面的,*(.init)表示所有輸入檔案中的.init節,*(.text)表示所有輸入檔案的.text節。在這裡我們只有一個.init節和一個.text節,這樣就可以保證,在輸出的.text節中,包含有輸入檔案的.init節和.text節,而且.init節在最前面。
將這個檔案儲存為ld.ld,然後就可以呼叫連結器來連結所有的檔案:
arm-linux-ld -T ld.ld init.o led.o -o led
生成的led ELF檔案,其中就包含有我們需要的.text節,可以通過arm-linux-objdump檢視。但是,對於ARM晶片來說,它不認識ELF檔案,我們還要想辦法將這個led檔案的.text節摳出來。這時候需要用到另一個命令:
arm-linux-objcopy -j ".text" -O binary led led.bin
該命令可以將led中的.text檔案copy出來,生成led.bin檔案。我生成的led.bin大小為148位元組,不同的編譯器可能產生的大小有點不一樣。這個led.bin就是我們最終需要的,能夠下載到記憶體0開始地方的程式碼。如果想反彙編這個led.bin檔案,還是可以用arm-linux-objdump。但是因為led.bin已經不是ELF檔案,arm-linux-objdump沒法知道這個檔案中哪裡開始是程式碼,是什麼型別的程式碼。需要通過命令列來告訴它:
arm-linux-objdump -b binary -m arm -D led.bin
上面的命令列引數就是告訴arm-linux-objdmp, led.bin是一個二進位制檔案,包含的是arm程式碼,請反彙編所有的內容。
接下來,通過openocd,用jtag連線上開發板,就可以準備執行程式碼了。下面是在openocd中執行命令的過程:
Open On-Chip Debugger
> reset halt
JTAG tap: s3c2440.cpu tap/device found: 0x0032409d (mfg: 0x04e, part: 0x0324, ver: 0x0)
target state: halted
target halted in ARM state due to debug-request, current mode: Supervisor
cpsr: 0x200000d3 pc: 0x00000000
MMU: disabled, D-Cache: disabled, I-Cache: disabled
NOTE! DCC downloads have not been enabled, defaulting to slow memory writes. Type 'help dcc'.
NOTE! Severe performance degradation without fast memory access enabled. Type 'help fast'.
> load_image led.bin 0 bin
148 bytes written at address 0x00000000
downloaded 148 bytes in 0.014759s (9.793 KiB/s)
> resume
>
可以看到,在輸入resume命令後,開發板上的LED就點亮了。如果感興趣,還可以在下載程式碼後,通過step命令單步執行,觀察這個小程式的執行過程。更多的命令可以檢視openocd的使用手冊。
雖然上面這個小程式已經可以運行了,但是還有一個問題忽略了。在這段程式中,目前還只能定義區域性變數,不能定義全域性變數。因為區域性變數是通過調整棧指標,在棧上面分配的。我們已經通過一小段彙編設定好了棧指標,因此區域性變數是沒有問題。但是全域性變數呢,編譯器怎麼知道全域性變數放在哪,怎麼可以訪問到呢?
全域性變數有兩種,初始化的和未初始化的。對於編譯和連結過程來說,如果是初始化的全域性變數,那麼在生成的可執行檔案中,一定要有該變數的值。這樣當可執行檔案被載入到記憶體時,這些值在記憶體中能被訪問到。而未初始化的變數,則不需要在可執行檔案中未其分配空間,因為本來就沒有值可以儲存。但是在執行時,要為這些變數分配空間,使程式碼能夠訪問他們。還有一種區域性靜態變數,其實在記憶體分配上,它和全域性變數是一樣的,只是在語法上,它的作用域和區域性變數一樣。程式碼經過編譯後,在彙編程式碼的層面上,它就和全域性變數沒有任何區別了。
還記得上面在反彙編一個目標檔案的時候,看到了.data節和.bss節。其中.data節就是存放初始化了的全域性變數的,而.bss節存放的是未初始化的全域性變數。比如下面這個例子:
[plain] view plain copy
- int a=1;
- int b=2;
- int c;
- int main()
- {
- c = 3;
- return a+b+c;
- }
儲存為test.c並編譯:
arm-linux-gcc -g -c test.c -o test.o
加上-g目的是使得輸出檔案中包含除錯資訊,便於反彙編時檢視。首先看看test.o中節的資訊:
arm-linux-objdump -x test.o
得到的結果比較長,下面只是一部分:
[plain] view plain copy- Sections:
- Idx Name Size VMA LMA File off Algn
- 0 .text 00000050 00000000 00000000 00000034 2**2
- CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
- 1 .data 00000008 00000000 00000000 00000084 2**2
- CONTENTS, ALLOC, LOAD, DATA
- 2 .bss 00000000 00000000 00000000 0000008c 2**0
- ALLOC
- 3 .debug_abbrev 00000045 00000000 00000000 0000008c 2**0
- CONTENTS, READONLY, DEBUGGING
結果基本還是比較符合預期的。.data節大小為8字節,因為兩個初始化的全域性變數。.bss節大小為0。進一步反彙編這段程式碼,可以看看這些變數是如何被訪問的:
arm-linux-objdump -S -d test.o
[plain] view plain copy- Disassembly of section .text:
- 00000000 <main>:
- int a = 1;
- int b = 2;
- int c;
- int main()
- {
- 0: e52db004 push {fp}