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
  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 plain copy
  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 plain copy
  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 plain copy
  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 plain copy
  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 plain copy
  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 plain copy
  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 plain copy
  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 plain copy
  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 plain copy
  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 plain copy
  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

你可能感兴趣的:(ARM裸机程序研究 - 编译和链接)