嵌入式Linux编程之交叉编译
源文件需要经过编译才能生成可执行文件。在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