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 plaincopy
  1. test.o:     file format elf32-i386  
  2. test.o  
  3. architecture: i386, flags 0x00000010:  
  4. HAS_SYMS  
  5. start address 0x00000000  
  6.   
  7. Sections:  
  8. Idx Name          Size      VMA       LMA       File off  Algn  
  9.   0 .text         0000000a  00000000  00000000  00000034  2**  
  10.                   CONTENTS, ALLOC, LOAD, READONLY, CODE  
  11.   1 .data         00000000  00000000  00000000  00000040  2**2  
  12.                   CONTENTS, ALLOC, LOAD, DATA  
  13.   2 .bss          00000000  00000000  00000000  00000040  2**2  
  14.                   ALLOC  
  15.   3 .comment      0000002b  00000000  00000000  00000040  2**0  
  16.                   CONTENTS, READONLY  
  17.   4 .note.GNU-stack 00000000  00000000  00000000  0000006b  2**0  
  18.                   CONTENTS, READONLY  
  19. SYMBOL TABLE:  
  20. 00000000 l    df *ABS*  00000000 test.c  
  21. 00000000 l    d  .text  00000000 .text  
  22. 00000000 l    d  .data  00000000 .data  
  23. 00000000 l    d  .bss   00000000 .bss  
  24. 00000000 l    d  .note.GNU-stack        00000000 .note.GNU-stack  
  25. 00000000 l    d  .comment       00000000 .comment  
  26. 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 plaincopy
  1. test.o:     file format elf32-i386  
  2.   
  3.   
  4. Disassembly of section .text:  
  5.   
  6. 00000000 <main>:  
  7.    0:   55                      push   %ebp  
  8.    1:   89 e5                   mov    %esp,%ebp  
  9.    3:   b8 00 00 00 00          mov    $0x0,%eax  
  10.    8:   5d                      pop    %ebp  
  11.    9:   c3                      ret  

   可以看見這裏就是一些棧的操作,沒有做什麼事情。當然,源碼裏面確實也沒做什麼事情。這個.o文件還不能執行,還需要經過鏈接。通常,我們可以用gcc一步完成編譯鏈接過程,也就是我們最常用的:

    gcc test.c -o test


    如果再次用objdump -d 反編譯生成的test文件:

    objdump -d test

    額……會發現多了一堆東西。這是因爲,c程序通常都是鏈接到c運行庫的。在main函數執行前,c運行庫需要初始化一些東西。這也說明,main()並不是程序的真正入口點。真正的入口點可以用objdump -f 查看test的文件頭:

[plain] view plaincopy
  1. test:     file format elf32-i386  
  2. architecture: i386, flags 0x00000112:  
  3. EXEC_P, HAS_SYMS, D_PAGED  
  4. 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 plaincopy
  1. .section .init  
  2. .global _init  
  3.   
  4. _init:  
  5.         ldr sp, =0x00001000  
  6.         bl mymain  
  7.   
  8. 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 plaincopy
  1. #define GPBCON  (*(unsigned long*)0x56000010)  
  2. #define GPBDAT  (*(unsigned long*)0x56000014)  
  3. #define GPBUP   (*(unsigned long*)0x56000018)  
  4. #define WTCON   (*(unsigned long*)0x53000000)  
  5. int mymain()  
  6. {  
  7.     WTCON = 0; /* turn off watch dog. */  
  8.   
  9.     unsigned long v = GPBCON;  
  10.     v &= 0xFFFc03FF;  
  11.     v |= 0x00015400;  
  12.     GPBCON = v;  
  13.   
  14.     v = GPBDAT;  
  15.     v &= ~0x000001e0;  
  16.     GPBDAT = v;    /* turn on all LEDs */  
  17.   
  18.     return 0;  
  19. }  

    關於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 plaincopy
  1. SECTIONS  
  2. {  
  3.     .text : {*(.init) *(.text)}  
  4. }  

    這個腳本中通過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 plaincopy
  1. int a=1;  
  2. int b=2;  
  3. int c;  
  4. int main()  
  5. {  
  6.     c = 3;  
  7.     return a+b+c;  
  8. }  

    保存爲test.c並編譯:

    arm-linux-gcc -g -c test.c -o test.o

   加上-g目的是使得輸出文件中包含調試信息,便於反彙編時查看。首先看看test.o中節的信息:

    arm-linux-objdump -x test.o

    得到的結果比較長,下面只是一部分:

[plain] view plaincopy
  1. Sections:  
  2. Idx Name          Size      VMA       LMA       File off  Algn  
  3.   0 .text         00000050  00000000  00000000  00000034  2**2  
  4.                   CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE  
  5.   1 .data         00000008  00000000  00000000  00000084  2**2  
  6.                   CONTENTS, ALLOC, LOAD, DATA  
  7.   2 .bss          00000000  00000000  00000000  0000008c  2**0  
  8.                   ALLOC  
  9.   3 .debug_abbrev 00000045  00000000  00000000  0000008c  2**0  
  10.                   CONTENTS, READONLY, DEBUGGING  

結果基本還是比較符合預期的。.data節大小爲8節,因爲兩個初始化的全局變量。.bss節大小爲0進一步反彙編這段代碼,可以看看這些變量是如何被訪問的:

    arm-linux-objdump -S -d test.o 

[plain] view plaincopy
  1. Disassembly of section .text:  
  2.   
  3. 00000000 <main>:  
  4. int a = 1;  
  5. int b = 2;  
  6. int c;  
  7. int main()  
  8. {  
  9.    0:   e52db004    push    {fp}        ; (str fp, [sp, #-4]!)  
  10.    4:   e28db000    add fp, sp, #0  
  11.     c = 3;  
  12.    8:   e59f3034    ldr r3, [pc, #52]   ; 44 <main+0x44>  
  13.    c:   e3a02003    mov r2, #3  
  14.   10:   e5832000    str r2, [r3]  
  15.     return a+b+c;  
  16.   14:   e59f302c    ldr r3, [pc, #44]   ; 48 <main+0x48>  
  17.   18:   e5932000    ldr r2, [r3]  
  18.   1c:   e59f3028    ldr r3, [pc, #40]   ; 4c <main+0x4c>  
  19.   20:   e5933000    ldr r3, [r3]  
  20.   24:   e0822003    add r2, r2, r3  
  21.   28:   e59f3014    ldr r3, [pc, #20]   ; 44 <main+0x44>  
  22.   2c:   e5933000    ldr r3, [r3]  
  23.   30:   e0823003    add r3, r2, r3  
  24. }  
  25.   34:   e1a00003    mov r0, r3  
  26.   38:   e28bd000    add sp, fp, #0  
  27.   3c:   e8bd0800    pop {fp}  
  28.   40:   e12fff1e    bx  lr  

    這裏有些比較有趣的地方,這些變量都是通過間接訪問得到的。比如在 “c = 3"過程中,彙編顯示從<main+0x44>這個地方讀入一個值,然後把3存入以這個值爲地址的變量。也就是說<main+0x44>這個地方保存的還不是變量c,而是c的地址。同樣,後面的<main+0x48> <main+0x4c>保存的是a b的地址。那麼這些地址到底是多少,在上面的反彙編中並沒有顯示出來,因爲他們不是代碼,不能反彙編。但是也並不難找到。從section的信息來看,.text節大小爲0x50(上面的反彙編代碼只有0x44個字節大小,所以後面的三個地址也是屬於.text節的)。起始於文件偏移0x34的地方,而main.text節中偏移爲0的地方,所以main在文件中的偏移也是0x34了,那麼<main+0x44>在文件中的偏移就是0x78了,而另兩個則是0x7c0x80。知道了位置,則可以用用下面的命令以16進制方式查看文件:

    hexdump -s 0x78 -n 12 -Cv test.o

   出來的結果居然都是0……a, b, c的地址都是0?這裏,還有一點點關於重定位的知識。

   編譯階段,所有節的位置都還是不確定的,可以看到節的VMA都是0編譯器還不知道節的位置,也就不知道那些變量的位置,自然也無法生成準確的代碼來引用那些變量。這些都要等到鏈接器來決定。鏈接器會安排好所有節的位置,然後修改上面的這些0,用真實的地址來替換。在剛生成的test.o中,包含有重定位信息,連接器就是根據這些信息來完成重定位。

   下面就是arm-linux-objdump -x輸出中的重定位信息,這裏只包含了.text

[plain] view plaincopy
  1. RELOCATION RECORDS FOR [.text]:  
  2. OFFSET   TYPE              VALUE   
  3. 00000044 R_ARM_ABS32       c  
  4. 00000048 R_ARM_ABS32       a  
  5. 0000004c R_ARM_ABS32       b  

   可以清楚的看到,在.text節偏移爲0x44, 0x48, 0x4c的地方,分別保存有變量c, a, b的地址(重定位類型R_ARM_ABS32)

   可以嘗試鏈接剛纔的程序,看看鏈接後是什麼樣子:

   arm-linux-ld test.o -e main -o test

   arm-linux-objdump -x test

[plain] view plaincopy
  1. Sections:  
  2. Idx Name          Size      VMA       LMA       File off  Algn  
  3.   0 .text         00000050  00008094  00008094  00000094  2**2  
  4.                   CONTENTS, ALLOC, LOAD, READONLY, CODE  
  5.   1 .data         00000008  000100e4  000100e4  000000e4  2**2  
  6.                   CONTENTS, ALLOC, LOAD, DATA  
  7.   2 .bss          00000004  000100ec  000100ec  000000ec  2**2  
  8.                   ALLOC  
  9.   3 .comment      00000011  00000000  00000000  000000ec  2**0  
  10.                   CONTENTS, READONLY  

    所有節的位置都已確定。雖然.bss節也有大小,但是這個大小是告訴ELF載器要分配多少內存給.bss,實際是不佔文件大小的。所以.bss節沒有“LOAD”標誌。這點,也可以從.bss和後面的.comment有相同的File offset可以看出。更進一步,可以用hexdump查看文件,看變量的地址是不是已經被鏈接器修改。不過.text節的偏移已經變成0x94,所以需要查看的偏移也相應的變成0xd8

   再回到我們真正想要的裸代碼上來。前面,我們只將生成的可執行文件中的.text節摳了出來,現在知道是不夠的,因爲丟掉了.data節,也就丟失了定義的全局變量。我們可以稍稍修改下鏈接腳本,將.data節合併到.text節中就可以了。新的鏈接腳本如下: 

SECTIONS

{

    .text : {*(.init) *(.text) *(.data)}

}                                                                                                           


  arm-linux-ld -T ld.ld test.o -o test鏈接後,再用arm-linux-objdump -x查看,發現已經沒有.data節了,而.text節的大小變成了0x58節。至於.bss節,本來,ELF載器會在內存中爲其分配空間。但是對我們來說,還沒有內存分配函數,只是將.bss放在.data節後面。對於裸代碼來說,只有.text(包含.data)是有預定義數據,還有內存末端的棧是要佔用一些內存的,其他都是自由的未使用的內存,這也正符合了.bss的需求。(本來應該將.bss也放入鏈接腳本,不過我發現ld會自動將.bss放在.data後面,所以這裏鏈接腳本就沒有列出.bss).   

    到此,已經完整了一個最基本的裸奔程序的製作,這應該只是一個開始,還有很多很多的東西要慢慢的去學習,加油!


相關代碼下載(SVN):http://arm-barecode.googlecode.com/svn/tags/01_init/

http://blog.csdn.NET/hulifox007/article/details/7406211

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章