一、RISC和CISC
1.1 RISC
上一章介绍过,通过指针操作寄存器,可以选择操作内存,也可以选择直接操作外设。这样是因为在ARM中,对于内存和外设他们是位于同一块存储空间内的。CPU访问他们的指令是一样的,根据访问地址的不同,判断操作的是内存还是寄存器。
ARM芯片属于精简指令集计算机(RISC:Reduced Instruction Set Computing),顾名思义,精简指令集就是指令简单。
1、 对内存只有读、写指令
2、 对于数据的运算是在CPU内部实现
3、使用RISC指令的CPU复杂度小一点,易于设计
对于RISC来说,计算a=a*b,CPU先读取内存中的a的值,然后再读取b的值,在CPU内部计算a+b,然后再把计算结果写回内存中,总共要经过四步。
1.2 CISC
在x86类型的CPU中,对于内存内和外设的操作是不同的。内存和外设是在两块不同的空间中,对于同一个地址,在不同的空间中,他们对应的具体内容是不同的。
x86CPU根据两种指令分别访问内存和外设。
x86属于复杂指令集计算机(CISC:Complex Instruction Set Computing),它所用的指令比较复杂,比如某些复杂的指令,它是通过“微程序”来实现的。
比如执行上面的乘法指令时,实际上指令会执行一个“微程序”,在“微程序”里,一样是去执行RISC中的4步操作:1、 读内存a;2、 读内存;3、 计算a*b;4、 把结果写入内存;也就是说,CPU只要执行1条指令,但是这个指令会执行一个程序,这个程序会完成所有对内存的读写操作。在宏观上来说,就是用一条指令完成计算。
二、ARM内部寄存器
上面说过,ARM-CPU对内存只有读和写两种操作,那么对于读和写的数值,CPU内部保存在哪里呢。
CPU内部寄存器是CPU可以直接访问的,不需要什么地址来访问。
Cortex-M3拥有通用寄存器R0‐R15以及一些特殊功能寄存器。
2.1 通用寄存器
R0‐R12是最“通用目的”的,但是绝大多数的16位指令只能使用R0‐R7(低组寄存器),而32位的Thumb‐2指令则可以访问所有通用寄存器。
R0‐R7也被称为低组寄存器。所有指令都能访问它们。它们的字长全是32位,复位后
的初始值是不可预料的。
R8‐R12也被称为高组寄存器。这是因为只有很少的16位Thumb指令能访问它们,32
位的指令则不受限制。它们也是32位字长,且复位后的初始值是不可预料的。
通用寄存器用来存放数据,程序中用到的各种数据都可以暂时存放到通用寄存器中。
2.2 R13(SP(Stack Pointer))栈指针
R13是栈指针。
对于M3和M4来说,SP有两个实体。在使用SP时,可能使用的是SP_main主栈寄存器,也有可能使用的是SP_process。在不同的情况和设置下,SP对应的实体也是不同的。一般使用的是SP_main,运行RTOS时用到SP_process。
2.3 R14(LR(Link Register))连接寄存器
用来保存返回地址,比如func_A中调用了func_B,调用func_B之前,返回地址保存在R14中,func_B执行完毕,根据R14中的地址,继续执行func_A。
2.4 R15(PC(Program Counter))程序计数器
存放当前正在执行的指令地址,这里面的地址是什么,程序就去哪里执行。向该寄存器写入新值程序就会立刻跳转到新的地址去执行。
2.5 PSR(Program Status Register)程序状态寄存器
程序状态寄存器用于保存某些状态的,比如某些寄存器的比较结果、上一条指令的执行结果、也有一些控制作用比如屏蔽中断、使能中断。
对于M3、M4来说,他有一个xPSR。
xPSR是三个不同类型的PSR组合而来的,xPSR = APSR + IPSR + EPSR。
对于这三个寄存器,可以通过指令单独的访问,也可以组合一起访问。
MSR写操作,(move PSR from reg)MSR PSR R0;把R0中的数据复制到PSR中hhhhh44455
MRS读操作,(move reg from PSR)MRS R0 PSR ;把PSR中的数据复制到R0中。
单独访问:MSR APSR R0 MSR EPSR R0 MSR IPSR R0
2.6 A7架构内部寄存器
上面说的寄存器都是对于M3、M4架构来说的。对于A7架构来说,大体上寄存器的结构式类似的。
上图可以看到,对于A7架构来说,仍然是有R0-R12共13个通用寄存器,R13(SP)栈指针、R14(LR)连接寄存器、R15(PC)程序计数器、CPSR程序状态寄存器。
不同的是,A7架构的CPU有很多种工作模式。在使用不同的工作模式下,使用的一些寄存器也会发生变化,每个工作模式会有自己特定的寄存器,可以实现某些特定的功能。
对于A7架构的PSR,与M3、M4并没有太大差别,主要是低9位的异常编号有所不同。
三、ARM汇编
3.1指令集
操作指令大体可以分为4类:
1、内存读写指令
2、运算指令
3、跳转/分支指令
4、比较指令
将所有操作指令组合在一起称为指令集,比如ARM指令集、Thumb指令集、Thumb-2指令集。
指令集 { A R M T h u m b T h u m b − 2 指令集\left\{ \begin{matrix} ARM \\\ Thumb\\Thumb-2\end{matrix} \right. 指令集⎩ ⎨ ⎧ARM ThumbThumb−2
ARM指令是32位的,Thumb指令是16位的。所以Thumb更节省空间。
上一节提到过,CPU内部有个PSR程序状态寄存器,其中的第24位表示当前使用的指令集是ARM指令集还是Thumb指令集。0–32位的ARM指令集;1–16位的Thumb指令集。
ARM指令集:32位,效率高,占用空间大。
Thumb指令集:16位,节省空间。
在代码中可以根据执行的空间和效率来选择使用哪一种指令集。想要效率使用ARM指令集,想要节省空间使用Thumb指令集。对于以前的ARM7、ARM9芯片以及现在的Cortex-A7芯片,既支持ARM指令集有支持Thumb指令集,混合编程的时候要用CODE16、CODE32具体指定某一段代码是ARM指令集还是Thumb指令集。
CODE16
func_A
....
CODE32
func_B
....
对于Cortex-M3、M4 的芯片来说,它又新引进了Thumb-2指令集,能够支持16、32位指令混合编程。CPU可以自动识别该语句是16位还是32位的,CPU不需要在ARM状态和Thumb状态下来回切换,程序执行非常高效。
3.2汇编指令
ARM公司推出了(Unified Assembly Language,UAL)统一汇编语言,不需要去区分这些指令集。在程序前面用CODE32/CODE16/THUMB表示不同的指令集。
对于不同的指令集,汇编指令的作用都是一样的。
3.2.1 数据处理指令
Operation表示各类汇编指令,比如ADD、MOV;
cond表示conditon,即该指令执行的条件;
S表示该指令执行后,会去修改程序状态寄存器;
Rd为目的寄存器,用来存储运算的结果;
Rn、Operand2是两个源操作数
3.2.2 内存访问指令
对内存只有读和写两种操作指令。
读内存指令LDR读一个寄存器,LDM读多个寄存器
写内存指令STR写一个寄存器,STM写多个寄存器
3.2.3 分支/跳转指令
B:跳转
BL:跳转前先把返回地址保持在LR寄存器中
BX:根据跳转地址的BIT0切换为ARM或Thumb状态(0:ARM状态,1:Thumb状态)
BLX:根据跳转地址的BIT0切换为ARM或Thumb状态(0:ARM状态,1:Thumb状态),并且在跳转前先把返回地址保持在LR寄存器中
3.3 立即数与伪指令
3.3.1 立即数
对于普通的汇编指令,是没有多余的bit位来存放任意的数值的,比如对于16位指令,除去操作和寄存器的bit位,留给数值的bit位就没几个了,所以一般的指令操作的数值不是任意数,而是一个立即数。
满足一下条件的数值即为立即数。
3.3.2 LDR伪指令
伪指令用于对任意数进行操作。编译器会把“伪指令”替换成真实的指令
LDR R0, =0x12,0x12是立即数,那么替换为:MOV R0, #0x12
LDR R0, =0x12345678,0x12345678不是立即数,那么替换为:
LDR R0, [PC, #offset] // 2. 使用Load Register读内存指令读出值,offset是链接程序时确定的
……
Label DCD 0x12345678 // 1. 编译器在程序某个地方保存有这个值
也就是说LDR伪指令操作立即数,则替换为简单的MOV指令;对于复杂的任意数,会转换为其他的比较复杂的读内存指令。
3.3.3 ADR伪指令
ADR:用来读某个标号的地址。
示例:
ADR R0, Loop
Loop
ADD R0, R0, #1
这里ADR是“伪指令”,会被转换成某条真实的指令,比如:
ADD R0, PC, #val ; val在链接时确定
Loop
ADD R0, R0, #1
四、ARM汇编模拟器和指令
4.1 简单介绍
模拟器没有模拟外设,仅仅模拟了CPU、ROM、RAM。
红色区域是ROM,不能读不能写,只能运行其中的程序;ROM区域本来可以读的,这是VisUAL的局限;RAM区域可读可写。
模拟器支持的地址空间是0-FFFFFFFC。
也可以查看内存。
4.2 指令实验
4.2.1 内存访问指令
对于ARM架构的CPU,访问内存或者外设寄存器时都是一样的,CPU发出某个地址,这个地址经过内存控制器分发给内存或者外设。所以无论是内存还是外设,访问的指令都是一样的。
对于内存访问只有读和写两种,上面介绍过,读写指令各有两个,分别是对单个和多个寄存器进行操作的。
LDR、LDM:读单个、多个寄存器
STR、STM:写单个、多个寄存器
4.2.1.1 读写单个寄存器
对于读写指令,比较简单,值得注意的就是操作的地址是否选择更新。
str r2,[r0 #4] ;把r2中的值写入r0中地址+4的地址中
str r2,[r0,#4]! ;把r2中的值写入r0中地址+4的地址中,然后更新r0,即r0=r0+4
读写指令常规用法有三种:
1、只对一个寄存器操作,如上图红框中的语句,STR R1,[R0],把r1的值写到R0对应地址中。
2、对寄存器进行运算后赋值,如上图绿色中的语句,对方括号中对寄存器的地址进行操作,加、减、左移、右移等。将值写入运算后的新地址中。例如:
STR R2,[R0,#4] ;R2的值写入R0+8的地址中
STR R2,[R0,R1,LSL #4] ;R2的值写入R0+(R1<<4)的地址中
3、对寄存器进行运算后赋值,然后更新寄存器。如上图蓝框中的语句,在方括号外面加!或者其他的数值或者运算表示对方括号中的地址写入完成后,将方括号中寄存器的内容进行更新。例如:
STR R2,[R0,#8]! ;R2的值写入R0+8的地址中,然后R0=R0+8
STR R2,[R0],#0x20 ;R2的值写入R0地址中,然后R0 = R0 + 0x20
也就是说,方括号后边加东西表示对方括号中的寄存器进行更新。
4.2.1.2 读写多个寄存器
使用LDM和STM可以同时对多个寄存器进行读写。
这里面有两个概念,增减和前后,两两组合有4种模式。
注意:低标号寄存器对应低地址。
STM{mode} R0,{R1-R3} ;假设R0地址为0x10
1、mode = IA-increment after:先赋值在增加地址。
R0 = R1,R0 = R0 + 4 = 0x14;
R0 = R2,R0 = R0 + 4 = 0x18;
R0 = R3,R0 = R0 + 4;
2、mode = IB-increment before:先增加地址在赋值。
R0 = R0 + 4 = 0x14,R0 = R1;
R0 = R0 + 4 = 0x18,R0 = R2;
R0 = R0 + 4 = 0x20,R0 = R3;
3、mode = DA-decrement after:先赋值在减小地址。
R0 = R3,R0 = R0 - 4 = 0x08;
R0 = R2,R0 = R0 + 4 = 0x04;
R0 = R1,R0 = R0 + 4;
4、mode = DB-decrement before:先减小地址在赋值。
R0 = R0 - 4 = 0x0c,R0 = R3;
R0 = R0 - 4 = 0x08,R0 = R2;
R0 = R0 - 4 = 0x04,R0 = R1;
4.2.1.3 栈的四种方式
上面的STM和LDM指令的四种模式也对应着栈的四种模式。
根据栈指针指向,可分为满(Full)/空(Empty):
满:SP指向最后一个入栈的数据,需要先修改SP再入栈。
空:SP指向下一个空位置,先入栈再修改SP。
满和空的状态就是当前的SP指向的内存是否有数据,有数据就是满状态,没有数据就是空状态。
根据压栈时SP的增长方向,可分为增/减:
增(Ascending):SP指向更大的地址,自增
减(Descending):SP指向更小的地址,自减
两两组合后,就有4种模式:
满增:先增加SP地址在赋值,对应STMIB。
满减:先减小SP地址在赋值,对应STMDB。
空增:先赋值在增加SP地址,对应STMIA。
空减:先赋值在减小SP地址,对应STMDA。
栈的用最常用的是“满减”模式:
入栈时用STMDB,也可以用STMFD。STMFD sp!, {r0-r5} ; Push onto a Full Descending Stack
出栈时用LDMIA,也LDMFD。LDMFD sp!, {r0-r5} ; Pop from a Full Descending Stack
也就是入栈时,先减在赋值,最后更新SP;出站时先赋值在自增,最后更新SP。注意要更新SP。
4.2.2 数据处理指令
数据处理指令有很多个,这里挑选一些比较常用的讲解。
4.2.2.1 加法指令
ADD R1, R2, R3 ; R1 = R2 + R3
ADD R1, R2, #0x12 ; R1 = R2 + 0x12
4.2.2.2 减法指令
SUB R1, R2, R3 ; R1 = R2 - R3
SUB R1, R2, #0x12 ; R1 = R2 - 0x12
4.2.2.3 位操作
VisUAL里不支持(1<<4)这样的写法,要将数值计算出来写成:0x10
按位与:AND R1, R2, #(1<<4) ;R1 = R2 & (1<<4)
AND R1, R2, R3; 位与,R1 = R2 & R3
按位或:ORR R1, R2, R3;R1 = R2 | R3
清楚某位(将某位置0):BIC R1, R2, #(1<<4);R1 = R2 & ~(1<<4)
BIC R1, R2, R3 ;R1 = R2 & ~R3
4.2.2.4 比较指令
CMP R0, R1; 比较R0-R1的结果
CMP R0, #0x12; 比较R0-0x12的结果
TST R0, R1; 测试 R0 & R1的结果
TST R0, #(1<<4) ; 测试 R0 & (1<<4)的结果
比较指令相当于C语言中的if语句,与某些在指令后面加某些后缀表示不同的情况。
比如上图的EQ表示上一条的比较指令的结果是相等。使用不同的后缀,执行不同的指令。
4.2.3 跳转指令
跳转指令就是让程序跳转到其他语句执行。
常用的跳转指令有B、BL、BX、BLX。
B:直接跳转,不保存返回地址。
BL:跳转前先把返回地址保存在LR寄存器中,当跳转到的程序执行完毕后,程序返回跳转前的地址继续执行。
BX:直接跳转,根据跳转地址的BIT0位决定使用哪种指令集;BIT0 = 0:ARM指令集,BIT0 = 1:Thumb指令集。
BLX:跟BX的作用一样,只不过也是在跳转之前把返回地址保存在LR寄存器中。
B指令直接跳转,后面也可以加后缀表示条件,如上图中的BNE表示R0不等于0的时候跳转,BEQ表示R0等于0的时候跳转。
上面loop循环中,当R0=13时,把第8行语句的地址保存在lr中,然后执行func1,func1执行完毕,把lr赋值给pc,程序就返回到了第8行继续执行。当R0=5的时候,直接跳转到func2,由于没有保存返回地址,所以func2执行完毕就直接结束了,不返回到循环。
一般最常用的就是B和BL这两条指令,模拟器不支持BX和BLX指令,这里就不在演示了。
使用ADR伪指令也可以读取标号的地址,然后复制给lr和pc寄存器。
在循环结束,把lr赋值给pc,也就是把ret的地址赋值给PC,程序就直接执行ret了。
五、深度解析程序
5.1 进制
5.1.1 常用进制
常用的进制有2进制、8进制、10进制、16进制。以此类推可以有n进制,逢n进1。
2进制:每一位的数值只有0和1两种,从右往左,每一位表示的权重分别是2^0, 2^1, 2^2, 2^3, 2^4……,例如10001 = 1x2^4 + 0x2^3 + 0x2^2 + 0x2^1 + 1x2^0 = 17
8进制:每一位数值为0-8,从右往左,每一位表示的权重分别是8^0, 8^1, 8^2, 8^3, 8^4……,例如021 = 2x8^1 + 1x8^0 = 17
10进制:每一位数值为0-9,从右往左,每一位表示的权重分别是10^0, 101,102, 103,104……,例如17 = 1x10^1 + 7x10^0 = 17
16进制:每一位数值为0-F(A=10,B=11,C=12,D=13,E=14,F=15),从右往左,每一位表示的权重分别是16^0, 161,162, 163,164……,例如11 = 1x16^1 + 1x16^0 = 17
0开头表示8进制,0b开头表示二进制,0x开头表示16进制。注意:C语言中没有二进制数值的表示语法。
5.1.2 进制转换
同一个数值,在不同的进制下的表现形式是不一样的,那么不同进制之间是如何转换的呢。
一般来说,不同进制之间的相互转换,都是通过一个中间枢纽–二进制实现的。
首先要记住2进制的权重,从左到右,第1位是23=8,第2位是22=4,第3位是21=2,第4位是20=1。
0b1100 = 1x8+1x4+0x2+0x1 = 12,记住8421这四个二进制权重就可以进行不同进制之间的快速转换。
1个八进制位对应3个二进制位,1个十六进制为对应4个二进制位。
例如037中,3 = 2+1,对应二进制就是011,7 = 4+2+1,对应二进制就是111,所以八进制037对应的二进制就是011111,对应十进制就是16+8+4+2+1 = 31
对于16进制,0x3F,3 = 2+1,对应二进制就是0011,F = 15 =8+4+2+1,对应二进制就是1111,所以0x5F转换为二进制就是00111111,对应十进制就是32+16+8+4+2+1=63
5.2 字节序与位操作
5.2.1 字节序
在理解字节序与位操作之前,首先要知道两个概念。
位:bit,简单理解就是一个二进制位,每个bit只能表示0或1。
字节:byte,8个bit组成1个byte,取值范围是00000000-11111111 = 0-255共256个。
之前说过,16进制每个位占用4个bit。所以1个字节可以表示两位16进制数值。
数据都是存储在内存之中,内存又分为高地址和低地址,根据数据的存储数据方向可以分为两种存储方式。
1、数据高位在高地址,低位在低地址。这种称为大字节序也称大端字节序。
2、数据高位在低地址,低位在高地址。这种称为小字节序也称小端字节序。
这两种模式也称为大小端。一般的ARM架构的芯片都是小字节序,有些处理器,可以置某个寄存器,让整个系统使用大字节序或小字节序。
5.2.2 位操作
下面介绍一些在嵌入式领域中常用的位操作。
5.2.2.1 移位操作
移位分为逻辑移位和算数移位。
逻辑移位:逻辑左移和逻辑右移,移出的空位都用0来补。例如1101左移1位就是11010,右移两位就是0011。逻辑移位只进行左右移位,移出的空位补0。
算数移位:对于需要区分有无符号类型。对于无符号类型的算数移位就等同于逻辑移位;对于有符号类型的算数移位,算术左移等同于逻辑左移,算术右移需要补符号位,正数补0,负数补1,因为二进制的符号位是最右边的位,0表示正数,1表示负数。
移位符号是由两个小于或大于号组成的。
<< //左移符号
>> //右移符号
逻辑左右移位可以表示乘除运算,而且移位运算要比乘除法运算快。所以可以在某些情况下,使用移位运算代替程序运算。
0b1100<<1 = 0b11000; //0b1100 = 12,0b11000 = 24
0b1100>>1 = 0b110; //0b1100 = 12,0b110 = 6
上边两个左右移位例子可以看出,左移1位表示乘21;右移1位表示除以21。以此类推,移位2位表示乘除2^2,移位3位表示乘除2^3…
5.2.2.2 取反操作
取反操作就是把数据的每一位都变为相反的二进制数值,0变成1,1变成0。
取反符号是一个波浪号。
~ //取反符号
unsigned int a = 0x3a;
unsigned int b = ~a;
5.2.2.3 位与
按位与就是每个两个数的每一个对应的bit位相与,简单理解就是A与B同为真(计算机中1表示真,0表示假),结果才为真。
位与的符号是&。
1 & 1 = 1
1 & 0 = 0
0 & 1 = 0
0 & 0 = 0
5.2.2.4 位或
位或简单理解就是A与B其中只要有1个为真,结果就是真。或操作符号是|。
1 | 1 = 1
1 | 0 = 1
0 | 1 = 1
0 | 0 = 0
5.2.2.5 置位
置位就是将数据的某一位置为1,而不改变其他位的原有数值。用左移和或操作组合实现。
int a,b,n;
b = a | (1 << n); //n表示第n位置为1
1<<2 = 0b100,因为进行的是或操作,所以第2位是1,1与任何数或都是1,而其他位是0,0与任何数或都是任何数本身,所以这样操作只会把第2位置1,其它所有位都保留原有的数值。
5.2.2.6 清位
与置位相反,清位是将某个位置0,也就是清除数据,而不改变其他位的原有数值,使用左移、与操作、取反操作组合实现。
int a,b,n;
b = a & ~(1 << n); //n表示第n位清除为0
上图可知,1左移2位再取反,结果是除了第2位其他位都是1,然后进行与操作,因为0与任意数都是0,1与任意数都是任意数本身,所以只有第2位被清0,其他所有的位保持原来的数值不变。
5.2.2.7 一次性设置多个bit
int val = 0x02; //0b10,
int a = 0xaa; //a = 0b10101010
a = a & ~(3<<3); //将第3第4位清0,10101010 & 00111 = 10100010
a = a | (val << 3); //将第3第4位置为所需要的值10, 10100010 | 10000 = 10110010
通过上述操作将数值的第3、4位变为1、0,使用val代表第3、4位的置位数。
操作流程就是首先将对应位清零,然后再赋值就行了。上述操作是用一条语句实现多个位清零置1的,也可以分为多步操作,先处理所有需要置1的位,在清除所有置0的位。
5.3汇编知识
5.3.1程序处理的4个步骤
1、.c文件经过预处理变成.i预处理文件
2、.i文件经过编译变为.s汇编文件
3、.s汇编文件经过汇编变为.o的可重定位目标文件
4、多个.o可重定位目标文件经过链接,变为.elf的可执行目标文件
汇编是把汇编指令变为机器码,反汇编是把机器码转换为汇编指令。同一条汇编指令,对于不同的架构,编译器生成的机器码可能是不同的。
5.3.2 设置keil生成反汇编文件
在KEIL的User选项中,如下图添加这两项:
fromelf --bin --output=led.bin Objects\led_c.axf
fromelf --text -a -c --output=led.dis Objects\led_c.axf
重新编译后,即可得到二进制文件led.bin、反汇编文件led.dis。
对于反汇编文件,.dis文件中,最左侧红框中是链接地址,也就是每条语句的地址;中间绿框中是机器码,CPU通过机器码执行对应的操作;最右边蓝框中是汇编指令,每1条汇编指令对应一个机器码和一个链接地址。
5.3.3 为何PC值不等于当前执行地址
在上图的手册中可以看到,执行ARM指令的时候,读PC的值,pc值为当前运行地址+8;执行Thumb、Thumb2指令的时候,当前PC值为当前运行地址+4;向PC寄存器中写入地址,程序就会跳转到指定地址去执行。
因为在CPU执行语句A的时候,同时已经对下一条指令进行译码,而且已经正在读取下下条指令了,所以程序正在执行A语句,但是PC寄存器已经跑到下下条语句去了,所以PC寄存器的值要加两个链接地址。
对于ARM指令集,PC=A+4 + 4 = A+8;对于Thumb\Thumb2指令集,PC=A+2+2=A+4;
5.3.4 汇编怎么调用C函数
1、对于没有参数的C函数,汇编直接使用跳转指令跳转即可。
bl main ;跳转到main执行
2、对于需要传入参数的C函数,例如delay(unsigned int count)
,要传入一个参数count。
ARM制定了ATPCS规则(ARM-THUMB procedure call standard(ARM-Thumb过程调用标准)。
通过ATPCS标准,规定了R0-R15寄存器各自的用途。
其中R0-R3用来给函数传参,存放函数返回值的;R4-R11用来存放函数的局部变量的。
int delay(unsigned int count)
{
int i = 100;
while(i--)
{
while(count--);
}
return 0;
}
再汇编中调用delay:
ldr r0, =100 /* 给delay函数传参数,保存在r0里 */
bl delay //跳转到delay执行
cmp r0, #0 /* 返回值保存在r0中 */
上面的delay函数,参数count保存在R0中,函数内部的局部变量i保存在R4-R11中。对于多个参数的函数,第一个参数保存在R0,第二个参数保存在R1,第三个参数保存在R3,第四个参数保存在R4。
R0-R11在函数调用过程中可能会被多次使用。这样会导致寄存器的值被覆盖,所以在函数调用之前,要先把寄存器的值保存起来,入栈。
5.4 分析反汇编代码
以LED闪烁实验为例分析程序的反汇编代码,深入理解CPU内部寄存器是如何工作的。
5.4.1 操作寄存器
1、使能GPIO的时候,首先把要操作的地址存入R3,地址是0x800017c,里面的内容是0x40021018(使能GPIO的寄存器)。然后把R3的地址赋值给R0,R0进行或操作,R0=R0|(1<<3)=8,最后把R0写回R3保存的地址中,也就是使能GPIO的寄存器.
2、设置GPIO为输出模式的步骤和第一步一样,都是先把要操作的寄存器写到R3中,然后读内存,或操作,在写回内存.
3、设置GPIO输出高低电平,首先设置寄存器,汇编代码中,先把寄存器加载到R3中,然后R3=R3+0xc,这样R3就等于要操作的寄存器了.
4、while循环:第一句先跳转到0x800017a执行,也就是最后一句,然后在从最后一句跳转到第二条语句执行,上面的红框中是设置GPIO输出1,操作步骤还是和之前一样,读寄存器,或操作,写寄存器,设置完GPIO输出为1之后,就进入delay的延时函数中,在跳转到另一个函数之前,先把被调用函数的参数写入R0寄存器,就是黑框中的第一句,0x18610 = 100000正时delay函数的参数值,将参数保存在R0之后,跳转到delay函数执行.注意使用的BL指令,把返回地址保存在LR寄存器之中,这样在delay执行完毕之后,程序能够通过LR寄存器中的值回到while循环中去继续执行.后面就是设置GPIO输出为0,然后进入delay函数,原理和之前一样,第二次delay执行完毕,程序就跳转到0x800015e去执行,回到上面,继续执行这个操作,这样就实现了无限循环。
通过上面的程序来看,汇编代码操作寄存器就三步,读地址,进行位运算,写地址。
5.4.2 函数调用与参数传递
5.4.2.1 函数的调用与返回
汇编中调用其他的函数其实就是使用跳转指令,是程序跳转到其他函数去执行。
通过BL指令,先把下一条指令的地址保存在LR寄存器中,然后再跳转到delay执行,这样实现了函数的调用;delay执行到最后会跳转到LR寄存器保存的地址去执行程序,这样就实现了函数的返回。
5.4.2.2 函数参数传递
之前说过,根据ATPCS规则,CPU内部寄存器R0-R3是负责传递参数和返回值的,下面根据delay函数分析是如何实现的。
delay_old:这个函数比较简单,会被编译器优化。
进入函数之前,参数100000保存在R0中,然后R1=R0-0=R0,这里用的指令是SUBS,后边的S表示计算结果会影响SPR寄存器,后面根据SPR寄存器的值进行条件判断.然后R0=R0-1,第三句就是条件跳转指令,BNE中NE表示SPR寄存器的Z标志位是0,(**当计算结果为0时,Z=1,反之Z=0)。**也就是判断R1是否等于0,R1不等于0,Z=0,BNE条件成立,跳转到上面语句继续执行,R1=0,Z=1,BNE条件不成立,就执行下面的BX指令,程序跳转到LR寄存器中的地址去执行.也就是delay_old执行完毕,程序返回之前的地址去执行.
这个函数被编译器优化了,内部的变量直接保存到CPU内部寄存器中去了,不涉及入栈出栈操作,所以程序执行的效率高.因为没有用到栈,所以在启动文件中不设置栈也是可以的,但是这是在程序非常极其的简单的情况下,正常情况不会有这样的所有函数都很简单的程序.所以绝大多数情况下,是必须用到栈的.
delay_new:这个函数参数被volatile修饰了,编译器不会对他进行优化,而且函数有返回值.
同样,在进入函数之前,参数100000保存在R0中,在函数内部,首先用PUSH
指令把R0和lr寄存器中的数据压入栈中,在蓝色方框内,对参数的操作不再是直接对寄存器操作了,而是每一次都是从栈中取出在写回.这样就导致了执行效率没有delay_old那样高.这种从栈中取出数据进行处理后在放回栈中的过程说明了变量是保存在栈中的.
然后通过MOV
指令,把返回值0xff保存在R0中.然后通过POP
指令,把之前的参数保存在R3中,在把LR寄存器中的数据放入PC寄存器中,函数就跳转到之前的程序去继续执行了.
5.2.3 程序烧写
CPU只能识别机器码,程序烧写,烧写的是对应的机器码,把所有的机器码存放在flash中.下图的地址是基于STM32F103ZE的芯片,对于不同类型的芯片,flash地址可能有所不同.
0x080000000
是系统上电后,程序运行的首地址.对应机器码是用来设置栈的,在上图中机器码是00000000,是因为我们自己在后面设置了栈,没有用他这个默认的设置栈.
0x0x0000004
是执行的第二条指令,对应的机器码是08000009,这部分是设置程序运行的指令集是16位的还是32位的,还有就是程序跳转地址.最后一位bit0为1则是16位的Thumb指令集,bit0等于0则是运行32位的ARM指令集,对于STM32F103只支持Thumb指令集,除了bit0,对于其他位是表示下一条指令的地址的,比如08000009除去最后一个bit0,就是08000008,程序就跳转到08000008地址去执行.
我们在0x08000008地址的指令中,设置了栈,然后跳转到main函数执行.
可以看到,在其他指令执行之前,首先要设置栈和指令集,才能去执行其他任务.
六、自己编写程序
6.1 汇编实现LED闪烁
根据上面的汇编知识,可以知道如何使用汇编操作寄存器并实现函数调用和参数传递等。下面是自己编写的汇编代码实现LED闪烁。
delay_A ;无参数,无返回值延时函数
ldr r1,=0x30d40 ;函数局部变量200000
loop_delay_A
subs r1,r1,#1 ;局部变量减1,计算结果影响PSR
bne loop_delay_A ;r1 != 0,继续循环
bx lr ;r1 = 0,循环结束,返回while循环
delay_B ;有参数,有返回值延时函数,使用栈操作
push {r0,lr} ;把参数5000和返回地址压入栈
nop
loop_delay_B
ldr r1,[sp,#0] ;读取栈中的数据,也就是200000
subs r2,r1,#1 ;局部变量减1保存在R2,计算结果影响PSR
str r2,[sp,#0] ;把更改后的变量保存在栈中
bne loop_delay_B ;r1 != 0,继续循环
mov r0,#0xff ;返回值保存在r0
pop {r3,pc} ;出栈,把lr内的地址赋给PC,程序跳转到返回地址继续执行
main
;使能GPIO时钟
ldr r1,=0x40021000
add r1,r1,#0x18 ;设置实真实地址=基地址+偏移地址
ldr r2,[r1] ;读寄存器
orr r2,r2,#8 ;位操作,第三位置1
str r2,[r1] ;写寄存器
;设置GPIO输出模式
ldr r1,=0x40010C00
add r1,r1,#0
ldr r2,[r1]
orr r2,r2,#1
str r2,[r1]
;设置GPIO输出高电平,灯灭
ldr r1,=0x40010C00
add r1,r1,#0X0c
ldr r2,[r1]
orr r2,r2,#1 ;bit0 = 1
str r2,[r1]
;while循环
loop
;调用延时函数delay_A--没有参数
bl delay_A ;没有参数,不用使用R0保存参数,直接跳转
;GPIO输出低电平,灯亮
ldr r1,=0x40010C00
add r1,r1,#0X0c
ldr r2,[r1]
bic r2,r2,#1 ;最低位清零,bit0=0
str r2,[r1]
;调用延时函数delay_B--有参数
ldr r0,=0x30d40 ;0x30d40=200000,把参数保存在R0中
bl delay_B ;跳转
;GPIO输出高电平,灯灭
ldr r1,=0x40010C00
add r1,r1,#0X0c
ldr r2,[r1]
orr r2,r2,#1 ;bit0 = 1
str r2,[r1]
b loop ;跳转到loop实现循环
Reset_Handler PROC
LDR SP, =0x20000000+0x100
BL main
ENDP
END
为了深入理解CPU内部寄存器的功能,使用了两个延时函数,一个没有参数,没有返回值,直接使用CPU内部寄存器读写数据;另一个有参数,有返回值,使用栈实现数据的读写。
注意事项:
1、函数调用时,跳转指令要用BL,返回地址保存到LR中,否则函数执行完毕无法回到原地址继续执行。
2、函数的参数保存在R0-R3中,在跳转之前先把参数保存在R0中。
3、对于简单的函数,编译器会给优化,数据直接保存在内部寄存器中,CPU直接操作内部寄存器,效率高。对于复杂的函数,数据会被放入栈中,数据从栈中拿出,在放回栈中。
4、ldr,str等指令,方括号表示操作的是方括号中内部寄存器保存的地址的数据。修改的是R0保存的地址指向的数据,而不是R0中的数据。