ARM跳转指令深度剖析(详细)
顺序、选择、循环是构建程序的基本结构,任何一个逻辑复杂的程序基本上都可以由这三种程序结构组合而成。而跳转指令,则在子程序调用、选择、循环程序结构中被大量使用。程序的跳转是如何实现的呢?在了解这个机制之前,我们需要先了解一下程序计数器PC。
程序计数器PC,是CPU的寄存器列表中最重要的一个寄存器。它就像一杆枪,指哪打哪:你给PC指针赋值哪个地址,CPU就会到PC指针指向的这个地址去取指令、翻译指令、执行指令。一般情况下,当你没有给PC指针赋新地址时,CPU在PC指针指向的地址取完指令后,PC计数器会自动加一,指向下一条指令,程序可以自动执行下去。当我们需要跳转时,可以直接给PC指针赋一个新地址,于是CPU就会跳转到新地址去执行了。
在ARM中,常见的跳转指令有B、BL、MOV、LDR等。不同的指令,它们的使用条件、使用场合是不同的,今天就给大家总结一下它们的区别及各自使用的场合。
B指令
B指令是ARM中最基本的跳转指令,它的使用方法如下:
B label
上面语句表示跳转到label的标号处去执行。B跳转指令是ARM中最简单的指令,只是单纯的跳转,而且是相对跳转。它可以跳到以当前位置PC为基址,前后32MB的地址空间范围,所以B指令只是在临近的代码块、标号之间跳转。
B指令跳转,大多数时候是单向的,跳过去就不再返回来了。但是我们可以通过添加一些标号来实现一些控制逻辑:比如循环、选择程序结构:
;循环结构示例
LOOP
SUB R0,R0,#1
...
CMP R0,#0
BNE LOOP
;选择结构示例
MOV R1,#10
MOV R2,#20
CMP R1,R2
BEQ HERE
...
B END
HERE
...
END
...
在上面的程序中,我们使用B跳转指令实现了选择、循环这两种基本的程序结构。B指令像ARM的其它指令一样,可以根据CPSR状态寄存器的标志位,有条件的执行。这样,可以减少指令数目、提高代码密度和运行效率。如BNE、BEQ就是当结果相等、不相等时的条件跳转。
当前程序状态寄存器:
各种各样的条件码:
BL指令
BL指令跟B不同:在跳转之前,会先将当前指令的下一条指令地址保存到LR寄存器中,然后才跳转到标号执行。这样做的好处是:当我们想从标号地方返回时,可以直接将LR寄存器中的返回地址赋值给PC,程序就可以返回到原来的程序中继续执行了。
BL跳转指令一般用在子程序的调用中。无论是汇编语言子程序,还是C语言子程序,在跳转到子程序之前,都要将返回地址保存起来。当子程序执行完毕,将LR寄存器保存的返回地址,重新赋值给PC,处理器就可以返回到主程序继续执行了。
BEGIN
MOV R0,#SRC
MOV R1,#DST
MOV R2,#100
BL COPY
NOP
...
COPY
SUB R2,R2,#1
LDR R3,[R0],#1
STR R3,[R1],#1
CMP R2,#0
BNE COPY
MOV PC,LR
上面的汇编代码段,我们定义了一个汇编子程序COPY,实现了数据拷贝的功能。当我们使用BL指令调用这个子程序COPY时,CPU会首先将当前指令的下一条指令:NOP 的地址保存到LR寄存器中,然后才跳转到COPY子程序去执行。在COPY子程序中,处理完数据搬运后,通过
MOV PC,LR
这条语句,将保存在LR寄存器中的返回地址,重新赋值给PC,这样我们就可以返回到原来的程序中继续执行了。
在上面的汇编代码中,LR,即R14,连接寄存器,常用来存放程序的返回地址;PC,即R15,程序计数器,表示当前指令地址。LR和PC都是ARM汇编器为了方便程序员编程,预定义的一些宏。你在程序中使用这些助记符其实就是相当于操作R14和R15寄存器。除此之外,ARM中常用的助记符有:
- FP:栈帧基址寄存器,即R12
- SP:栈指针寄存器,即R13
- LR:链接寄存器,即R14
- PC:程序计数器,即R15
同样,在C语言调用子函数的过程中,在跳转子函数执行之前,CPU也会将当前指令的下一条指令地址保存到LR寄存器中,然后再跳转到子函数中执行。因为在子函数运行过程中,也有可能会用到ARM的一些寄存器,也有可能会调用其它的子函数,会覆盖掉保存在LR寄存器中的返回地址,所以,我们一般在运行子函数之前,会首先将LR寄存器压入子函数的栈帧,相当于将返回地址保存到了栈上。当子函数运行结束时,再通过出栈操作,将保存在栈中的返回地址弹出到PC指针中,这样程序就成功从子程序中返回了,直接返回到原来的函数中继续执行。
int main(void)
{
func();
printf("Hello zhaixue.cc!\n");
return 0;
}
;对应的汇编代码
main
BL func
BL printf
func
PUSH LR
...
pop pc
;func子函数返回
MOV指令
通过上面的学习,我们可以看到,无论是B指令、还是BL指令,都是相对寻址。其本质都是以当前指令地址PC为基址,然后加上一个[0,32M]的偏移,达到修改PC的目的。
除此之外,我们也可以直接给PC指针赋值,达到跳转的目的。如上面的 func 子程序返回,就是直接通过
MOV PC,LR
这条指令,将LR寄存器中的返回地址,直接赋值给PC,直返回到原来的主函数去执行。
MOV指令主要用来在寄存器之间传输数据,或者将一个立即数传送到寄存器。但是MOV指令有一个硬伤,就是传递的立即数只能是8位数,有大小的限制。这是为什么呢?很简单,ARM是RISC架构,在一个32位的ARM中,指令通常都是32位的。而一个指令中,通常要包括操作码+操作数,如下图
一条指令,总共有32个bit空间,MOV这个操作码要占几位吧,Rd寄存器编码要占据几位吧,剩下的留给立即数的空间就不多了,所以这也就限定了MOV指令能传递的立即数的大小了。而一般的32位程序中,无论是变量还是函数,它们的地址一般都是32位的,如果使用MOV指令,将他们的地址传送到PC,使用下面的形式:
MOV PC,#0x30008000
你会发现,立即数#0X30008000这个地址就已经32位了,在加上MOV指令这个操作码,已经超过32位了,编译器是无法翻译这个指令的,所以说,当一个变量或函数地址为32位时,使用MOV指令给PC直接赋值,行不通,那怎么办呢?
LDR伪指令
办法总是有的,比如,我们就可以通过伪指令LDR,直接将一个32位的立即数地址,传送到PC:
LDR PC,=0x30008000
LDR伪指令的功能和MOV一样,都可以将一个立即数传送到寄存器。唯一区别的就是,MOV指令只能传送8位的,而LDR可以传送一个32位的立即数或地址。
这里需要注意一下,立即数 0X30008000 的前面有一个等于号“=”,这表示前面的LDR指令是一个伪指令。除此之外,在ARM中,LDR还有另外一个意思,用来将内存中的数据加载到寄存器。我们知道,ARM是RISC架构,使用LDR/STR架构,不能直接修改内存中的数据。如果我们要修改内存中的一个变量,要首先使用LDR指令将内存中的变量加载到寄存器中,接着对寄存器进行操作,最后再使用STR指令将寄存器中的变量回写到内存中。所以,LDR可以看作是一个伪指令,也可以看做是普通的一个LDR指令,判定他们的区别就是看前面的等于号。
普通的LDR指令主要使用寄存器间接寻址,常用的使用方式如下:
LDR R0,[R1]
LDR R0,0x30008000
这里注意后面一句,是将地址0x30008000地址上的内容传动到寄存器R0,而不是直接将这个地址传送到R0,这里一定要注意其跟LDR伪指令的区别,这一点没有注意到,你在分析程序时就可能误入歧途了。
在《C语言嵌入式Linux高级编程》第二期中,我们已经探讨了CPU、指令集、伪指令的基本概念,这里就不赘述了。简单来说,伪指令并不是真正的ARM指令,并不属于ARM指令集中的标准指令。它只是编译器为了方便我们程序员开发程序,定义的一些助记符。在编译时,这些伪指令还是会使用指令集中的标准指令来实现。
比如上面的LDR伪指令,程序在编译时,看到这个伪指令,会使用ARM指令集中标准的指令实现。如果LDR伪指令中的立即数小于8位,它就会转换为MOV指令来实现:
LDR R0,=200
MOV R0,#200
如果LDR伪指令中,立即数大于8bit表示的数据范围,比如说是一个32位的立即数或地址,那就不能使用MOV指令来实现了,可以采用文字池的形式,先将这个地址常量单独存放在存储单元中,然后使用相对寻址,曲线救国,完成这个32位地址或立即数与寄存器之间的传输,这些细节在教程视频中都有讲到,就不再赘述了。
小结
通过上面的学习,我们基本上理清了ARM系统中常见的几种跳转指令,以及它们的区别。只有彻底理解他们的底层机制及实现细节,才有可能在使用反汇编分析程序时,达到事半功倍的效果,从而大大提高我们的工作效率。否则,这些基本的细节和概念搞不清,将会永远成为你学习和工作上的障碍。