程序项目代做,有需求私信(vue、React、Java、爬虫、电路板设计、嵌入式linux等)

嵌入式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进制、二进制,都仅仅是对同一个数据的不同表达形式而已,这些不同的表达形式也仅仅是为了方便我们人类(又说了这个词一遍)读写而已,它们所表示的数值及它在计算机中的保存方式是完全一样的。

参考文章:

【1】gcc和arm-linux-gcc

【2】嵌入式Linux应用开发完全手册

【3】arm-linux-ld

posted @ 2021-05-29 23:02  大奥特曼打小怪兽  阅读(1843)  评论(0编辑  收藏  举报
如果有任何技术小问题,欢迎大家交流沟通,共同进步