嵌入式编程基础知识
《嵌入式Linux应用完全开发手册》第1篇第3章总结归纳
本章目标
- 了解交叉编译工具链的各种选项
- 掌握连接脚本的编译方法
- 了解Makefile文件中常用的函数
- 了解几个常用的ARM汇编指令
- 了解汇编程序调用C函数所遵循的ATPCS规则
交叉编译工具选项说明
源文件需要经过编译才能生成可执行文件。在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“等。预处理就是将要包含的文件插入原文件中、将宏定义展开、根据条件编译命令选择要使用的代码,最后将这些代码输入到一个”.i“文件中等待进一步处理。预处理将要用到arm-linux-cpp工具。 - 编译
编译就是把c/c++代码”翻译“汇编代码,所用到的工具为ccl(它的名字就是ccl,而不是arm-linux-ccl)。 - 汇编
汇编就是将第二步输出的汇编代码翻译成一定格式的机器代码,在Linux系统上一般表现为ELF(OBJ文件),用到的工具为arm-linux-as。”反汇编“是指将机器代码转换为汇编代码,这在调试程序时常常用到。 - 连接
连接就是将上步生成的OBJ文件和系统库的OBJ文件、库文件连接起来,最终生成可以在特定平台运行的可执行文件,用到的工具是arm-linux-ld。
编译器利用这4个步骤中的一个或者多个来处理输入文件,源文件的后缀名表示源文件所用的语言,后缀名控制着编译器的默认动作。
后缀名 | 语言种类 | 后期操作 |
---|---|---|
.c | c源程序 | 预处理、编译、汇编 |
.C | c++源程序 | 预处理、编译、汇编 |
.cc | c++源程序 | 预处理、编译、汇编 |
.cxx | c++源程序 | 预处理、编译、汇编 |
.m | Object-C 源程序 | 预处理、编译、汇编 |
.i | 预处理后的c文件 | 编译、汇编 |
.ii | 预处理后的c++文件 | 编译、汇编 |
.s | 汇编语言源程序 | 汇编 |
.S | 汇编语言源程序 | 预处理、汇编 |
.h | 预处理器文件 | 通常不出现在命令行上 |
其他后缀名的文件被传递给连接器(linker),通常包括一下两种:
.o:目标文件(Object file,OBJ文件)。
.a:归档库文件(Archive file)。
在编译过程中,除非使用了”-c“,”-S“,或者”-E“选项,或者编译错误组织了完整的编译过程,否则最后的步骤总是连接。在连接阶段中,所有对应于源程序的.o文件、”-l“选项指定的库文件、无法识别的文件名(包括指定的”.o“目标文件和”.a“库文件)按命令行中的顺序传递给连接器。
以一个简单的”Hello World“C程序为例:
1 | /*File : hello.c*/ |
使用arm-linux-gcc,只需要一个命令就可以生成可执行文件hello,它包含了4个步骤:
1 | $ arm-linux-gcc -o hello hello.c |
加上”-v“选项,可以查看编译的细节:
1 | ccl hello.c -o /tmp/cctETob7.s |
以上三个命令分别对应于编译步骤中的预处理+编译、汇编和连接,ld被collect2调用来连接程序。预处理和编译被放在了一个命令中(ccl)进行,可以把它再次拆分为一下两步:
1 | cpp -o hello.i hello.c |
可以通过各种选项来控制arm-linux-gcc的动作,下面介绍一些常用的选项。
总体选项
- -c
预处理、编译和汇编源文件,但是不作连接,编译器根据源文件生成OBJ文件。默认情况下,GCC通过用”.o“替换文件名的后缀”.c“,”.i“,”.s“等,产生OBJ文件名。可以使用”-o“选项选择其他名字。GCC忽略”-c“选项后面任何无法识别的输入文件。 - -S
编译后即停止,不进行汇编。对于每个输入的非汇编语言文件,输出结果是汇编语言文件。默认情况下,GCC通过用”.s“替换源文件名后缀”.c“,”.i“等,产生汇编文件名。可以使用”-o“选项选择其他名字。GCC忽略任何不需要汇编的输入文件。 - -E
预处理后即停止,不进行编译。预处理后的代码送往标准输出。GCC忽略任何不需要预处理的输入文件。 - -o file
指定输出文件为file。无论下是预处理、编译、汇编还是连接,这个选项都可以使用, - -v
显示制作GCC工具自身时的配置命令;同时显示编译器驱动程序、预处理器、编译器的版本号。
以一个程序为例,它包含三个文件:使用上述命令进行编译:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20File:main.c
int main(int argc,char *argv[])
{
int i;
printf("Main fun\n");
sub_fun();
return 0;
}
File:sub.h
void sub_fun(void);
File:sub.c
void sub_fun(void)
{
pintf("Sub fun\n");
}其中,main.o、sub.o是经过了预处理、编译、汇编后生成的OBJ文件,它们还没有被连接成可执行文件;最后一步将它们连接成可执行文件test,可以直接运行以下命令:1
2
3$ gcc -c -o main.o main.c
$ gcc -c -o sub.o sub.c
$ gcc -o test main.o sub.o现在试试其他选项,以下命令生成的main.s是main.c的汇编语言文件:1
2
3$ ./test
Main fun
Sub fun以下命令对main.c进行预处理,并将得到的结果打印出来,里面包含了所有包含的文件、所有定义的宏。在编写程序时,有时候查找某个宏定义是非常繁琐的事情,可以使用”-dM-E“选项来查看。1
$ gcc -S -o main.s main.c
1
$ gcc -E main.c
警告选项
”-Wall“选项基本打开了所有需要注意的警告信息,比如没有指定类型的声明、在声明前就使用的函数、局部变量除了声明就没有再使用等。
编译上面的main.c文件如下:
1 | $ gcc -Wall -c main.c |
得到的警告信息如下
1 | main.c:In function "main" |
这个警告虽然对程序没有坏的影响,但是有些警告需要加以关注,比如匹配类型的警告等。
调试选项
-g: 加入只有GDB才使用的额外调试信息。
优化选项
- -O或者-O1
优化:对于大多数函数,优化编译的过程将占用较长的时间和相当大的内存。不使用”-O“选项的目的是减少编译的开销,使编译结果能够调试、语句是独立的。如果在两条语句之间用断点中止程序,可以对任何变量进行赋值,或者在函数体内把程序计数器指到其他语句,以及从源程序中精确获取所期待的结果。
不使用”-O“或者”-O1“选项时,只有声明了register的变量才分配使用寄存器。
使用了”-O“或者”-O1“选项时,编译器会试图减少目标码的大小和执行时间。如果指定了”-O“或者”-O1“选项,”-fthread-jumps“和”-fdefer-pop“选项被打开。在有delay slot的机器上,”-fdelayed-branch“选项将被打开。在既没有帧指针又支持调试的机器上,”-fomit-frame-pointer“选项将被打开。某些机器上还可能打开其他选项。 - -O2
多优化一些。除了涉及空间和速度的优化选项,执行几乎所有的优化工作。例如不进行循环展开和函数内嵌。和”-O“选项相比,这个选项既增加了编译时间,也提高了生成代码的运行效果。 - -O3
优化的更多,除了打开”-O2“所做的一切,它还打开了”-finline-functions“选项。 - -O0
不优化。
如果指定了多个”-O0“选项,不管带不带数字,生效的是最后一个选项。
链接器选项
下面的选项用于连接OBJ文件,输出可执行文件或者库文件。
- object-file-name
如果某些文件没有特别明确的后缀。GCC就认为它是OBJ文件或者库文件。如果GCC执行连接操作,这些OBJ文件就会成为连接器的输入文件。例如:main.o和sub.o就是输入的文件。1
$ gcc -o test main.o sub.o
- -llibrary
连接名为library的库文件。
连接器在搜索标准目录中寻找这个库文件,库文件的真正名字”liblibrary.a“。搜索目录除了一些系统标准目录外,还包括用户以”-L“选项指定的路径。一般来说用这个方法找到的文件就是库文件---即由OBJ文件组成的归档文件
。连接器处理归档文件的方法是:扫面归档文件,寻找某些成员,这些成员的符号目前已被引用,不过还没有被定义。但是,如果连接器普通的OBJ文件,而不是库文件,就把这个OBJ文件按照平常方式连接进来。指定”-l“选项和指定文件名的唯一区别是,”-l“选项用”lib“和”.a“把library包裹起来,而且搜索一些目录。
即使不明显的使用”-llibrary“选项,一些默认的库也被连接进去,可以使用”-v“选项看到这点。输出的信息如下:1
$ gcc -v -o test main.o sub.o
可以看见,除了main.o、sub.o两个文件外,还连接了启动文件crtl.o、crti.o、crtend.o、crtn.o,还有一些库文件(-lgcc、-lgcc_eh、-lc、-lgcc、-lgcc_eh)。1
2
3
4
5
6
7
8
9
10
11
12/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/collect2 --eh-frame-hdr -m elf-i386-dynamic-linker /lib/ld-linux.so.2
-o test
/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/../../../crtl.o
/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/../../../ctri.o
/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/crtbegin.o
-L/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/
-L/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/../../..
main.o
sub.o
-lgcc -lgcc_eh -lc -lgcc -lgcc_eh
/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/crtend.o
/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/../../../crtn.o - -nostartfiles
不连接系统标准启动文件,而标准库文件依然正常使用:输出的信息如下:1
$ gcc -v -nostartfiles -o test main.o sub.o
可以看见启动文件ctll.o、ctri.o、crtend.o、crtn.o没有被连接进去。需要说明的是,对于一般应用程序,这些启动文件是必须的,这里仅是作为例子(这样编译出来的test文件无法执行)。在编译bootloader、内核时,将用到这个选项。1
2
3
4
5
6
7
8
9/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/collect2 --eh-frame-hdr -m elf-i386-dynamic-linker
/lib/ld-linux.so.2
-o test
-L/usr/lib/gcc-lib/i386-redhat-linux/3.2.2
-L/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/../../..
main.o
sub.o
-lgcc -lgcc_eh -lc -lgcc -lgcc_eh
/usr/lib/ld:warning:cannot find entry symbol _start;defaulting to 08048184 - -nostdlib
不连接系统标准启动文件和标准库文件,只把指定的文件传递给连接器,这个选项常用于编译内核,bootloader等程序,它们不需要启动文件,标准库文件。仍以options程序作为例子:输出的信息如下:1
$ gcc -v -nostdlib -o test main.o sub.o
出现了一大堆错误,因为printf等函数是在库文件中实现的。在编译bootloader、内核时,用到这个选项,它们用的很多函数都是自包含的。1
2
3
4
5
6
7
8
9
10
11
12
13/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/collect2 --eh-frame-hdr -m elf-i386-dynamic-linker
/lib/ld-linux.so.2
-o test
-L/usr/lib/gcc-lib/i386-redhat-linux/3.2.2
-L/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/../../..
main.o
sub.o
/usr/bin/ld:warning:cannot find entry symbol _start;defaulting to 08048074
main.o(.text + 0x19):In function "main":
:undefined referendce to "printf"
sub.o(.text + 0xf):In function "sub_fun":
:undefined reference to "printf"
collect2:ld returned 1 exit status - -static
在支持动态连接的的系统上阻止连接共享库。
仍以options程序作为例子,使用和不使用”-static“选项编译出来的可执行程序大小相差巨大:其中test文件为6591字节,test_static为546479字节。当不使用”-static“编译文件时,程序执行前要连接共享库文件,所以还需要将共享库文件放入文件系统中。1
2
3
4
5
6
7$ gcc -c -o main.c
$ gcc -c -o sub.c
$ gcc -o test main.o sub.o
$ gcc -o test_static mian.o sub.o -static
$ ls -l test test_static
6591 test
546479 test_static - -shared
生成一个共享OBJ文件,它可以和其他OBJ文件连接产生可执行文件。只有部分系统支持该选项。
当不想以源代码发布程序时,可以使用”-shared“选项生成库文件,比如对于options程序,可以如下制作库文件:以后要使用sub.c中的sub_fun时,在连接程序时,将sub.a加入即可。比如:1
2$ gcc -c -o sub.o sub.c
$ gcc -shared -o sub.a sub.o可以将多个文件制作为一个库文件,比如:1
$ gcc -o test main.o ./sub.a
1
$ gcc -shared -o sub.a sub.o sub2.o sub3.o
- -Xlinker option
把选项option传递给连接器。可以用来传递系统特定的连接选项,GCC无法识别这些选项。如果需要传递携带参数的选项,必须使用两次”-Xlinker“,一次传递选项,另一次传递其参数。例如,如果传递”-arsset-definitions“,要写成”-Xlinker -assert -Xlinker definitions“,而不能写成”-Xlinker -assert definitions“。因为这样会把整个字符串当作一个参数传递,显然不是连接器期待的。 - -Wl,option
把选项option传递给连接器 。如果option中含有逗号,就在逗号处分割成多个选项。连接器通常都是通过gcc,arm-linux-gcc等命令间接启动的,要向他传入参数时,参数前面加上”-Wl,“。 - -u symbol
使连接器认为取消了symbol的符号定义,从而连接库模块以取得定义。可以使用多个”-u“选项,各自跟上不同的符号,使得连接器调入附加的模块。
目录选项
下列选项指定搜索路径,用于查找头文件,库文件或者编译器的某些成员。
- -Idir
在头文件的搜索路径列表添加dir目录。头文件的搜索方法为:如果以”#include <>“包含文件,则只会在标准库目录开始搜索(包括使用-Idir选项定义的目录);如果以”#include “包含文件,则先从用户的工作目录开始搜索,再搜索标准库目录。 - -I-
任何在”-I-“前面用”-I“选项指定的搜索路径只适用于”#include ’file‘“这种情况;它们不能用来搜索”#include<>“包含的头文件。如果用”-I“选项指定的搜索路径位于”-I-“选项后面,就可以在这些路径中搜索所有的”#include“指令。”-I-“选项能够阻止当前目录成为搜索”#include ’file‘“的第一选择。 - -Ldir
在”-L“选项的搜索路径中添加dir目录,仍然使用options程序进行说明,先制作库文件:编译main.c:1
2$ gcc -c -o sub.o sub.c
$ gcc -shared -o libsub.a sub.o连接程序,下面的指令将会出错,提示找不到库文件:1
$ gcc -c -o main.o main.c
可以使用”-Ldir“选项将当前目录加入搜索路径,如下则连接成功:1
2
3$ gcc -o test main.o -lsub
/usr/bin/ld: cannot find lsub
collect2: ld returned 1 exit status1
$ gcc -L. -o test main.o -lsub
arm-linux-ld选项
arm-linux-ld将用于多个目标文件、库文件连接成可执行文件。
直接指定代码段、数据段、bss段的起始地址
格式如下:
1 | -Ttext startaddr |
其中的”startaddr“分别代表代码段、数据段和bss段的起始地址,它是一个十六进制数。
1 | $ arm-linux-ld -Ttext 0x00000000 -g led_on.o -o led_on_elf |
它代表的代码段的运行地址为0x00000000,由于没有定义数据段、bss段的起始地址,它们被依次放在代码段的后面
。
以一个例子来说明”-Ttext“选项的作用:
1 | .text |
使用下面的命令编译、连接、反汇编:
1 | arm-linux-gcc -c -o link.o link.s |
例子中用到了两种跳转方法:b跳转指令、直接向pc寄存器赋值。先列出不同“-Ttext”选项下生成的反汇编文件,在详细分析由于不同运行地址带来的差异及影响。两个反汇编文件如下:
1 | link_0x00000000.dis link_0x30000000.dis |
“b step1”是个相对跳转指令,其机器码的格式如下:
- [31:28]位是条件码。
- [27:24]位为“1010”时,表示b跳转指令;为“1011”时,表示b1跳转指令。
- [23::0]表示偏移地址。
使用“b”或者“b1”跳转时,下一条指令的地址是这样计算的:将指令中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”选项,生成的代码仍旧是一样的。
“ldr pc, =step2”,从汇编码“ldr pc, [pc, #0]”可以看出,这条指令从内存中的某个位置读出数据,并赋值给PC寄存器。这个位置的地址是当前pc寄存器的值加上偏移量0。其中存放的值依赖于连接命令的“-Ttext”选项。执行这条指令之后,对于link_0x00000000.dis,pc = 0x0000000;对于link_0x30000000.dis,pc = 0x30000008。执行第三条指令“b step2”后,程序的运行地址就不同了:分别是0x00000008、0x30000008。
Bootloader、内核程序刚开始执行时,它们所处的地址通常不等于运行地址。在程序的开头,先使用b、bl、mov等“位置无关”的指令将代码从Flash等设备复制到内存的“运行地址”处,然后再跳转到“运行地址”去执行。
使用连接脚本设置地址
1 | arm-linux-ld -Ttimer.lds -o timer_elf head.o init.o interrupt.o main.o |
它使用连接脚本timer.lds来设置可执行文件timer_elf的地址信息,timer.lds文件内容如下:
1 | SECTIONS { |
解析timer.lds文件之前,先讲解连接脚本的格式。连接脚本的基本命令是SECTIONS命令,它描述了输出文件的映射图:输出文件中各段、各文件怎么怎么放置。一个SECTIONS命令内部包含一个或者多个段。段(Section)是连接脚本的基本单元,它表示输入文件中的某部分怎么放置。
完整的连接脚本格式如下,它的核心部分是段(Section):
1 | ··· |
secname 和 contents是必需的,前者用来命名这个段。后者用来确定代码中的什么部分放在这个段中。
start是这个段重定位地址,也称为运行地址。如果代码中有位置无关的指令,程序在运行时,这个段必须放在这个地址上。
ALIGN(align):虽然start指定了运行地址,但是仍然可以使用BLOCK(align)来指定对齐的要求—这个对齐的地址才是真正的运行地址。
(NOLOAD):用来告诉加载器,在运行时不用加载这个段。显然,这个选项只有在操作系统的情况下才有意义。
AT(ldadr):指定这个段在编译出来的映像文件中的地址—加载地址。如果不使用这个选项,则加载地址等于运行地址。通过这个选项,可以控制各段分别保存输出文件中不同的位置,便于把文件保存到单板上:A段放在A处,B段放在B处,运行前再把A、B段分别读出来组装成一个完整的执行程序。
现在开始分析下timer.lds文件:
- 第2行表示设置“当前运行地址”为0x30000000。
- 第3行定义了一个名为“.text”的段,它的内容为“* (.text)”,表示所有输入文件的代码段。这些代码段被集合在一起,起始运行地址为0x30000000。
- 第4行定义了一个名为“.rodata”的段,在输出文件timer_elf中,它紧挨着“.text”段存放。其中“ALIGN(4)”表示起始运行地址为4字节对齐。假设前面“.text”段的地址范围是0x30000000-0x300003f1,则“.rodata”段的地址为4字节对齐后的0x300003f4。
- 第5、6行的含义与第4行类似。
arm-linux-objcopy选项
arm-linux-objcopy被用来复制一个目标文件的内容到另一个文件中,可以使用不同于源文件的格式来输出目的文件,即可以进行格式转换。
这本书中,常用arm-linux-objcopy来将ELF格式的可执行文件转换为二进制文件。下面讲解常用的选项:
- input-file、outfile
参数input-file和outfile分别表示输入目标文件和输出目标文件。 - -I bfdname 或 –input-target=bfdname
用来指明源文件的格式,bfdname是BFD库中描述的标准格式名。如果不指明源文件格式,arm-linux-objcopy会自己去分析源文件的格式,然后去和BFD中描述的各种格式比较,从而得知源文件的目标格式名。 - -O bfdname 或 –output-target=bfdname
使用指定的格式来输出文件,bfdname是BFD库中描述的的标准格式名。 - -F bfdname 或 –target=bfdname
同时指明源文件和目的文件的格式。将源文件的内容复制到目的文件的过程中,只进行复制而不做格式转换,源文件是什么格式,目的目标文件就是什么格式。 - -R sectionname 或 –remove-section=sectionname
从输出文件中删除掉所有名为sectionname的段。这个选项可以多次使用。 - -S 或 –strip-all
不从源文件复制重定位信息和符号信息到目标文件中去。 - -g 或 –strip-debug
不从源文件中复制调试符号到目标文件中去。
在编译bootloader、内核时,常用arm-linux-objcopy命令将ELF格式的生成结果转换为二进制文件,比如:1
$ arm-linux-objcopy -O binary -S elf_file bin_file
arm-linux-objdump选项
arm-linux-objdump用于显示二进制文件信息,本书中常用来查看反汇编代码。下面讲解常用的选项:
- -b bfdname 或 –target=bfdname
指定目标码格式。这不是必须的,arm-linux-objdump能自动识别许多格式。可以使用“arm-linux-objdump -i”命令查看支持的目标码格式。 - –disassemble 或 -d
反汇编可执行段。 - –disassemble 或 -D
反汇编所有段。 - -EB或-EL或–endian={big|little}
指定字节序。 - –file-headers或-f
显示文件的整体头部摘要信息。 - –section-headers、–header或-h
显示目标文件各个段的头部摘要信息。 - –info或-i
显示支持的目标文件格式和CPU架构,它们在“-b”、“-m”选项中用到。 - –section=name或-j name
仅显示指定section的信息。 - –architecture=machine或-m machine
指定反汇编目标文件时使用的架构,当待反汇编文件本身没有描述架构信息的时候,这个选项很有用。可以用“-i”选项列出这里能够指定的架构。
在调试程序时,常用arm-linux-objdump命令来得到汇编代码。1
2
3
4/*将ELF格式的文件转换为反汇编文件*/
$ arm-linux-objdump -D elf_file > dis_file
/*将二进制文件转换为反汇编文件*/
$ arm-linux-objdump -D -b binary -m arm bin_file > dis_file
汇编代码、机器码和存储器的关系以及数据的表示
即使使用C/C++或者其他高级语言编程,最后也会被编译工具转换为汇编代码,并最终作为机器码存储在内存、硬盘或者其他存储器上。在调试程序时,经常需要阅读它的汇编代码:
1 | 4bc: e3a0244e: mov r2, #1308622848; |
4bc、4c0、4c4是这些代码的运行地址,就是说运行前,这些指令必须位于内存中的这些地址上;e3a0244e、e3a0344e、e5933000是机器码。运行地址、机器码都是十六进制表示。CPU用到的、内存中保存的都是机器码示意如下:
1 | /*内存中的示意图*/ |
“mov r2, #1308622848”、“mov r3, #1308622848”、“ldr r3, [r3]”是上述几个机器码的汇编代码。所谓汇编代码仅仅是为了方便读写而引入的,机器码和汇编代码之间也仅仅是简单的转换关系。参考CPU的数据手册,ARM的数据处指令格式为:
以机器码0xe3a0244e为例:
- [31:28] = 0b1110,表示这条指令无条件执行。
- [25] = 0b1,表示Operand2是一个立即数。
- [24:21] = 0b1101,表示这是MOV指令。
- [20] = 0b0,表示这条指令执行时不影响状态位。
- [15:12] = 0b0010,表示Rd就是r2寄存器。
- [11:0] = 0x44e,这是一个立即数。
立即数占据机器码的低12位表示:最低8位的值称为immed_8,高4位称为rotate_imm。立即数的数值计算方法为:= immed_8 循环右移 (2 * rotate_imm)。对于”[11:0] = 0x44e“,其中immed_8 = 0x4e、rotate_imm=0x4,所以此立即数为0x4e000000。
Makefile介绍
在Linux中使用Make命令来编译程序,特别是大程序;而make命令所执行的动作依赖与Makefile文件。最简单的Makefile文件如下:
1 | hello:hello.c |
将上述4行存为Makefile文件(注意必须以Tab键来进行缩进第2、4行,不能以空格来进行缩进),执行make即可编译程序,执行make clean即可清除编译出来的结果。
make命令根据文件更新的时间戳来决定哪些文件需要重新编译,这使得可以避免编译已经编译过的、没有变化的程序,大大提高编译效率。
Makefile规则
一个简单的Makefile文件包含一系列”规则“,其样式如下:
1 | 目标(target)...:依赖(prerequiries)... |
目标(target)通常是要生成的文件的名称,可以是可执行文件或者OBJ文件,也可以是一个执行的动作名称,诸如”clean“。
依赖是用来产生目标的材料(比如源文件),一个目标通常有几个依赖。
命令是生成目标时的动作,一个规则可以含有几个命令,每个命令占一行。
通常,如果一个依赖发生了变化,就需要规则调整命令以更新或者创建目标。但是并非所有的目标都有依赖,例如,目标”clean“的作用就是清除文件,它没有依赖。
规则一般是用于解释怎样和何时重建目标。make首先调用命令处理依赖,进而才能创建或者更新目标。当然,一个规则也可以是用于解释怎样和何时执行一个动作,即打印提示信息。
一个Makefile文件可以包含规则以外的其他文本,但一个简单的Makefile文件仅仅需要包含规则。虽然真正的规则比这里展示的例子复杂,但是格式是完全一样的。
对于上述Makefile,执行”make“命令时,仅当hello.c文件比hello文件新,才会执行命令”arm-linux-gcc -o hello hello.c“生成可执行文件hello;如果还没有hello文件,这个命令也会执行。
运行”make clean“,由于目标没有依赖项,它的命令“rm -f hello”将被强制执行。
Makefile文件里的赋值方法
变量的定义如下:
1 | immediate = deferred |
在GNU make中对变量的赋值有两种方式:延时变量、立即变量。区别在于它们的定义方式和扩展时的方式不同,前者在这个变量使用时才会扩展开,意思就是当真正使用这个变量时才会确定;后者在定义时它的值就已经确定了。使用“=”、“?=”定义或者使用define指定定义的变量是延时变量;使用“:=”定义的变量是立即变量。需要注意一点的的是“?=”仅仅在变量没有定义的情况下有效,即“?=”用来定义第一次出现的延时变量。对于附加操作符“+=”,右边变量如果在前面使用(:=)定义为立即变量则它也是立即变量,否则均为延时变量。
Makefile常用函数
函数调用的格式如下:
1 | $(function arguments) |
这里“function”是函数名,“arguments”是该函数的参数。参数与函数名之间用空格或者Tab隔开,如果有多个参数,它们之间用逗号隔开。这些空格和逗号不是参数值的一部分。下面介绍一些常用的Makefile函数。
字符串替换和分析函数
- $ (subst from,to,text)
在文本“text”中使用“to”替换每一处“from”。比如:1
2$ (subst ee,EE,feet on the street)
==>fEEt on the strEEt - $ (patsubst pattern,replacement,text)
寻找“text”中符合格式“pattern”的字,用“replacement”替换它们。“pattern”和“replacement”中可以使用通配符。比如:1
2$ (patsubst %.c,%.o,x.c.c bar.c)
==>x.c.o bar.o - $ (strip string)
去掉前导和结尾空格,并将中间的多个空格压缩为单个空格。比如:1
2$ (strip a b c )
==>a b c - $ (findstring find,in)
在字符串“in”中查找“find”,如果找到,则返回值是“find“,否则返回值为空。比如:1
2
3
4$(findstring a,a b c)
==>a
$(findstring a,b c)
==> - $(filter pattern…,text)
返回在”text“中由空格隔开且匹配格式”pattern…“的字,去除不符合格式”pattern…“的字。比如:1
2$(filter %.c %.s,foo.c bar.c baz.s ugh.h)
==>foo.c bar.c baz.s - $(filter-out pattern…,text)
返回在”text“中由空格隔开并且不匹配格式”pattern…“的字,去除符合格式”pattern…“的字。它是函数filter的反函数。比如:1
2$(filter %.c %.s,foo.c bar.c baz.s ugh.h)
==>ugh.h - $(sort list)
将”list“中的字按照字母顺序排列,并去掉重复的字。输出由单个空格隔开的字的列表。比如:1
2$(sort foo bar lose)
==>bar foo lose
文件名函数
- $(dir names…)
抽取”names…“中每一个文件名的路径部分,文件名的路径部分包括从文件名的首字符到最后一个斜杠之前的一切字符。比如:1
2$(dir src/foo.c hacks)
==>src/ ./ - $(notdir names…)
抽取”names…“中每一个文件名的除路径之外的一切字符。比如:1
2$(dir src/foo.c hacks)
==>foo.c hacks - $(suffix names…)
抽取”names…“中每一个文件名的后缀。比如:1
2$(dir src/foo.c hacks)
==>.c - $(basename names…)
抽取”names…“中每一个文件名除后缀以外的一切字符。比如:1
2$(dir src/foo.c hacks)
==>src/foo hacks - $(addsuffix suffix,names…)
参数”names…“是一系列的文件名,文件名之间用空格隔开;suffix是一个后缀名。将suffix(后缀)的值附加在每一个独立文件的后面,完成后将文件名串联起来,它们之间用单个空格隔开。比如:1
2$(addsuffix .c,foo bar)
==>foo.c bar.c - $(addprefix prefix,names…)
参数”names…“是一系列的文件名,文件名之间用空格隔开;prefix是一个前缀名。将prefix(前缀)的值附加在每一个独立文件的前面,完成后将文件名串联起来,它们之间用单个空格隔开。比如:1
2$(addprefix src/,foo bar)
==>src/foo src/bar - $(wildcard pattern)
参数”pattern“是一个文件名格式,包含有通配符(通配符和shell的用法一样)。函数wildcard的结果是一列和格式匹配且真实存在的文件的名称,文件名之间用一个空格隔开。
比如当前目录下有1.c、2.c、1.h、2.h。则:1
2c_src := $(wildcard *.c)
==>1.c 2.c
其他函数
- $(foreach var,list,text)
前两个参数,”var“和”list“将首先扩展,最后一个参数”text“此时不扩展;接着,”list“扩展得到的每个字都赋值给”var“变量;然后,”text“引用该变量进行扩展,因此”text“每次扩展都不相同。1
2
3
4
5
6
7dirs := a b c d
files := $(foreach dir,$(dir),$(wildcard $(dir)/*))
==>
$(wildcard a/*)
$(wildcard b/*)
$(wildcard c/*)
$(wildcard d/*) - $(if condition,then-part[,else-part])
首先把第一个参数”condition“的前导空格、结尾空格去掉,然后扩展。如果扩展为非空字符产,则条件”condition“为真,那么计算第二个参数”then-part“的值,并将之作为函数的返回值。如果condition为假,并且第三个参数存在,则计算第三个参数”else-part“的值,并将该值作为函数得返回值。如果第三个参数不存在,则返回空。 - $(origin variable)
变量”variable“是一个查询变量的名称,不是对改变量的引用。所以不能采用”$“和圆括号的格式书写该变量,当然,如果需要使用非常量的文件名,可以在文件名中使用变量引用。函数的返回值如下:1
2
3
4
5
6
7
8undefined :变量”variable“从未被定义;
default :变量”variable“是默认定义;
environment :变量”variable“作为环境变量定义,选项”-e“没有打开;
environment override :变量”variable“作为环境变量定义,选项”-e“已打开;
file :变量”variable“在Makefile中定义;
command line :变量”variable“在命令行中定义;
override :变量”variable“在Makefile中用override指令定义;
automatic :变量”variable“是自动变量。 - $(shell command arguments)
函数shell是Makefile与外部环境通信的工具。函数shell的执行结果和在控制台上执行”command arguments“的结果相似。不过如果”command arguments“的结果含有换行符,则在函数shell的返回结果中将它们处理为单个空格,若返回结果最后是换行符或者回车符则被去掉。
比如当前目录下有1.c、2.c、1.h、2.h。则:下面以一个Makefile为例进行演示:1
2c_src := $(shell ls *.c)
==>1.c 2.c上述Makefile中”$@“、”$^“、”$<“称为自动变量。”$@“表示规则的目标文件名;”$^“表示所有依赖的名字,名字中间用空格隔开;”$<“表示第一个依赖的文件名。1
2
3
4
5
6
7
8
9
10
11
12File:Makefiel
src := $(shell ls *.c)
objs := $(patsubst %.c,%.o,$(src))
test: $(objs)
gcc -o $@ $^
%.o:%.c
gcc -c -o $@ $<
clean:
rm -rf test *.o
已知当前目录下的所有文件为Makefile、main.c、sub.c、sub.h。
第一行src变量的值为”main.c sub.c“。
第二行objs变量的值为”main.o sub.o“。
第四行实际上就是:目标test的依赖项是main.o sub.o。开始时这两个文件还没有生成,在执行文件生成test的命令前先将main.o、sub.o作为目标查找合适的规则,以生成main.o、sub.o。1
test : main.o sub.o
第七八行就是用来生成main.o、sub.o的规则:这样,test的依赖main.o和sub.o就生成了。1
2
3
4
5main.o : main.c
gcc -c -o main.o main.c
sub.o : sub.c
gcc -c -o sub.o sub.c
常用ARM汇编指令及ATPCS规则
在嵌入式开发中,汇编程序常常用于非常关键的地方,比如系统启动时的初始化,进出中断时的环境保存、恢复,对性能要求非常苛刻的函数等。
- 相对跳转指令b、bl
这两条指令的不同之处在于bl指令除了跳转之外,还将返回地址(bl的下一条指令的地址)保存在lr寄存器中。
这两条指令的可跳转范围是当前指令的前后32M。
它们是位置无关的指令。
使用示例:1
2
3
4
5
6
7b fun1
...
fun1:
bl fun2
...
fun2:
... - 数据传送指令mov,地址读取伪指令ldr
mov指令可以把一个寄存器的值赋值给另一个寄存器赋给另一个寄存器,或者把一个常数赋值给寄存器。mov传送的常数必须能用立即数来表示。1
2
3
4
5/*r1 = r2*/
mov r1,r2
/*r1 = 4096*/
mov r1,#4096
当不知道一个数能否用”立即数“来表示时,可以使用ldr命令来赋值。ldr是伪指令,它不是真实存在的指令,编译器会把它扩展成真正的指令:如果该常数能用”立即数“来表示,则使用mov指令;否则编译时将该常数保存在某个位置,使用内存读取指令把它读出来。1
2
3
4
5
6
7/*r1 = 4097*/
ldr r1, =4097
/*r1 = label的绝对地址*/
ldr r1, =label
label:
... - 内存访问指令:ldr,str,ldm,stm
ldr指令从内存中读取数据到寄存器,str指令把寄存器的值存储到内存,它们操作的数据都是32位的。示例如下:ldm和stm属于批量内存访问指令,只用一条指令就可以读写多个数据。它们的格式如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15/*将地址为r2+4的内存单元数据读取到r1*/
ldr r1,[r2,#4]
/*将地址为r2的内存单元数据读取到r1*/
ldr r1,[r2]
/*将地址为r2的内存单元数据读取到r1,然后r2 = r2 + 4*/
ldr r1,[r2],#4
/*将r1的数据保存到地址为r2 + 4的内存单元*/
str r1,[r2,#4]
/*将r1的数据保存到地址为r2的内存单元*/
str r1,[r2]
/*将r1的数据保存到地址为r2的内存单元,然后r2 = r2 + 4*/
str r1,[r2],#4其中{cond}表示地址变化模式,有以下4种模式:1
2ldm{cond}<addressing_mode> <rn>{!} <register list> {^}
stm{cond}<addressing_mode> <rn>{!} <register list> {^}1
2
3
4ia (Increment After):事后递增模式
ib (Increment Before):事先递增模式
da (Decrement After):事后递减模式
db (Decrement Before):事先递减模式中保存内存的地址,如果在后面加上了感叹号,指令执行后,rn的值会更新,等于下一个内存单元的地址。 表示寄存器列表,对于ldm指令,从 所对应的内存块取出数据。写入这些寄存器;对于stm指令,把这些寄存器的值写入 所对应的内存块中。
{^}有两种含义:如果中有PC寄存器,它表示指令执行后,spsr寄存器的值将自动复制到cpsr寄存器中—这常用于从中断处理函数返回;如果 中没有pc寄存器,它表示操作的是用户模式下的寄存器,而不是当前特权模式的寄存器。
指令中寄存器列表和内存单元的对应关系为:编号低的寄存器对应内存中的低地址单元,编号高的寄存器对应内存中的高地址单元。
1 | HandleIRQ: @中断入口函数 |
- 加减指令:add、sub
1
2add r1,r2,#1 /*表示r1 = r2 + 1,即寄存器r1的值等于r2的值加上1*/
sub r1,r2,#1 /*表示r1 = r2 - 1*/ - 程序状态字寄存器的访问指令:msr、mrs
ARM处理器有一个程序状态字寄存器cpsr,它用来控制处理器的工作模式、设置中断的总开关。1
2msr cpsr,r0 /*复制r0到cpsr中*/
mrs r0,cpsr /*复制cpsr到r0中*/ - 其他伪指令
在本书的汇编程序中,常常见到如下语句:”.extern“定义一个外部符号(可以是变量也可以是函数),上面的代码表示本文件中引用的main是一个外部函数。1
2
3
4.extern main
.text
.global _start
_start:
”.text“表示下面的语句都属于代码段。
”.global“将本文件的某个程序标号定义为全局的,比如下面的代码表示_start是个全局函数。 - 汇编指令的执行条件
大多数ARM指令都可以条件执行,即根据cpsr寄存器中的条件标志决定执行该指令:如果条件不满足,该指令相当于一条nop指令。
每条ARM指令包含4位的条件域码,这表明可以定义16个执行条件。可以将这些执行条件的助记符附加在汇编指令后,如moveq,movgt等。
条件码 | 助记符 | 含义 | cpsr中条件标志位 |
---|---|---|---|
0000 | eq | 相等 | Z=1 |
0001 | ne | 不相等 | Z=0 |
0010 | cs/hs | 无符号数大于/等于 | C=1 |
0100 | mi | 负数 | N=1 |
0101 | pl | 非负数 | N=0 |
0110 | vs | 上溢出 | V=1 |
0111 | vc | 没有上溢出 | V=0 |
1000 | hi | 无符号数大于 | C=1 && Z=0 |
1001 | ls | 无符号数小于等于 | C=0 |
1010 | ge | 带符号数大于等于 | N=1,V=1 |
1011 | lt | 带符号数小于 | N=1,V=0 |
1100 | gt | 带符号数大于 | Z=0 && N=V |
1101 | le | 带符号数小于/等于 | Z=1 |
1110 | al | 无条件执行 | - |
1111 | nv | 从不执行 | - |
表中的cpsr条件标志位N、Z、C、V分别表示Negative、Zero、Cary、Overflow。影响条件标志位的因素比较多,比如比较指令cmp、cnm、teq及tst等。
ARM-THUMB子程序调用规则ATPCS
为了使C语言程序和汇编程序之间能够相互调用,必须为子程序之间的调用制定规则,在ARM处理器中,这个规则被称为ATPCS:ARM程序和Thumb程序中子程序的调用的规则。
基本的ATPCS规则包括寄存器使用规则、数据栈使用规则、参数传递规则等。
寄存器使用规则
ARM处理器中有r0-r15共16个寄存器,它们的用途有一些约定的习惯,并依据这些这些用途定义了别名。
寄存器 | 别名 | 使用规则 |
---|---|---|
r15 | pc | 程序计数器 |
r14 | lr | 连接寄存器 |
r13 | sp | 数据栈指针 |
r12 | ip | 子程序内部调用的scratch寄存器 |
r11 | v8 | ARM状态局部变量寄存器8 |
r10 | v7、sl | ARM状态局部变量寄存器7、在支持数据栈检查的ATPCS中为数据栈限定指针 |
r9 | v6、sb | ARM状态局部变量寄存器6、在支持RWPI的ATPCS中为静态基址寄存器 |
r8 | v5 | ARM状态局部变量寄存器5 |
r7 | v4、wr | ARM状态局部变量寄存器4、Thumb状态工作寄存器 |
r6 | v3 | ARM状态局部变量寄存器3 |
r5 | v2 | ARM状态局部变量寄存器2 |
r4 | v1 | ARM状态局部变量寄存器1 |
r3 | a4 | 参数/结果/scratch寄存器4 |
r2 | a3 | 参数/结果/scratch寄存器3 |
r1 | a2 | 参数/结果/scratch寄存器2 |
r0 | a1 | 参数/结果/scratch寄存器1 |
寄存器的使用规则总结如下:
- 子程序间通过寄存器r0-r3来传递参数,这时可以使用它们的别名a0-a3。被调用的子程序返回前无须恢复r0-r3的内容。
- 在子程序中,使用r4-r11来保存局部变量,这时可以使用它们的别名v1-v8。如果在子程序中使用了它们的某些寄存器,子程序进入时需要保存这些寄存器的值,在返回时需要恢复它们;对于子程序没有使用到的寄存器,则不必进行这些操作。在Thumb程序中,通常只能使用寄存器r4-r7来保存局部变量。
- 寄存器r12用作子程序间scratch寄存器,别名ip。
- 寄存器r13用作数据栈指针,别名sp。在子程序中寄存器r13不能用作其他用途。它的值在进入、退出子程序时必须相等。
- 寄存器r14称为连接寄存器,别名lr。它用于保存子程序的返回地址。如果在子程序中保存了返回地址(比如将lr的值保存到数据栈中),r14可以用作其他用途。
- 寄存器r15是程序计数器,别名pc。它不能用作其他用途。
数据栈使用规则
数据栈有两个增长方向:向内存地址减小的方向增长时,称为DESCENDING栈;向内存增加的方向增长时,称为ASCENDING栈。
所谓数据栈的增长就是移动栈指针。当栈指针指向栈顶元素时,称为FULL栈;当栈指针指向栈顶元素相邻的一个空的数据单元时,称为EMPTY栈。
使用stmdb命令往数据栈保存内容时,先递减sp指针,再保存数据,使用ldmia命令从数据栈恢复数据时,先获得数据,再递增sp指针,sp指针总是指向栈顶元素。
参数传递规则
一般来说,当参数个数不超过4个时,使用r0-r3这4个寄存器来传递参数;如果参数超过4个,剩余的参数通过数据栈来传递。
对于一般的返回结果,通常使用a0-a3来传递。示例:
假设CopyCode2SDRAM函数是用C语言实现的,它的数据原型如下:
int CopyCode2SDRAM(unsigned char *buf,unsigned long start_addr,int size);
在汇编代码中,使用下面的代码调用它,并判断返回值。
1 | ldr r0,=0x30000000 |
第一行将r0设为0x30000000,则CopyCode2SDRAM函数执行时,它的第一个参数buf的指向的内存地址是0x30000000。
第二行将r1设为0,CopyCode2SDRAM函数的第二个参数start_addr等于0。
第三行将r2设为16x1024,CopyCode2SDRAM函数的第三个参数size等于16x1024。
第五行判断返回值。