由于在实验一中已经配置好Acadia的网络设置,所以这次直接插上网线,使用ssh进行远程登录。
实验目的
- 深入理解ARM指令和Thumb指令的区别和编译选项;
- 深入理解某些特殊的ARM指令,理解如何编写C代码来得到这些指令;
- 深入理解ARM的BL指令和C函数的堆栈保护;
- 深入理解如何实现C和汇编函数的互相调用。
实验步骤
1. arm与thumb指令集
经过查阅gcc的编译选项,找到了编译为arm的编译指令-marm以及编译为thumb的编译指令-mthumb。
使用程序验证如下,
root@Acadia:~/tmp/hello# cat hello.c
#include
#include
int hello(){
printf("Hello Acadia! At %lu\n", time(NULL));
}
int main(){
hello();
return 0;
}
root@Acadia:~/tmp/hello# gcc hello.c -marm -o arm.out
root@Acadia:~/tmp/hello# gcc hello.c -mthumb -o thumb.out
root@Acadia:~/tmp/hello# ll *.out
-rwxr-xr-x 1 root root 7856 Mar 26 10:44 arm.out*
-rwxr-xr-x 1 root root 7856 Mar 26 10:45 thumb.out*
root@Acadia:~/tmp/hello# echo disas hello | gdb arm.out > arm_hello.s
root@Acadia:~/tmp/hello# echo disas hello | gdb thumb.out > thumb_hello.s
root@Acadia:~/tmp/hello# vimdiff thumb_hello.s arm_hello.s
首先,测试程序中调用了内部的函数hello,同时也调用了外部函数printf以及time。而通过gcc编译后,两个文件大小无差异,故不能从中直接获知是否为不同指令集的文件。
此时,需要使用gdb反汇编hello,进行比较后方可以看出生成程序的不同。测试命令中使用echo是为了命令的清晰,并且容易使用输出重定向比较结果。
程序之间最大的区别是在函数主体部分。主要区别有几点:
- thumb的指令地址一次只增加2,即每条指令只占16位。相对的,arm每个地址增加4,每条指令占位32位。
- thumb的指令集访问寄存器 r8 ~ r15 受到一定限制,所以在thumb程序中可以看出,使用的是r7寄存器代替了arm程序中的r11寄存器。
- thumb程序可以调用arm指令集的函数库,但是地址跳转需要使用blx进行指令集转换。而arm程序直接使用bl跳转即可。
2. arm上的条件命令
root@Acadia:~/tmp/if# cat if.c
#include
int main(){
int n, m;
scanf("%d", &m);
if (m > 32){
n = 5;
}else{
n = 1;
}
printf("%d\n", n);
return 0;
}
root@Acadia:~/tmp/if# gcc if.c -marm -o if.out -O2
...
root@Acadia:~/tmp/if# echo disas main | gdb if.out > if_main.s
root@Acadia:~/tmp/if# cat if_main.s
...
(gdb) Dump of assembler code for function main:
0x00008374 <+0>: push {lr} ; (str lr, [sp, #-4]!)
0x00008378 <+4>: sub sp, sp, #12
0x0000837c <+8>: add r1, sp, #4
0x00008380 <+12>: ldr r0, [pc, #40] ; 0x83b0
0x00008384 <+16>: bl 0x835c <__isoc99_scanf> ; r0 = "%d", r1 = &m, 调用scanf
0x00008388 <+20>: ldr r2, [sp, #4]
0x0000838c <+24>: mov r0, #1
0x00008390 <+28>: ldr r1, [pc, #28] ; 0x83b4
0x00008394 <+32>: cmp r2, #32 ; 执行if命令,使用cmp后条件赋值
0x00008398 <+36>: movle r2, r0
0x0000839c <+40>: movgt r2, #5
0x000083a0 <+44>: bl 0x8350 <__printf_chk> ; r2直接传入printf进行输出
0x000083a4 <+48>: mov r0, #0
0x000083a8 <+52>: add sp, sp, #12
0x000083ac <+56>: pop {pc}
0x000083b0 <+60>: andeq r8, r0, r4, lsl #9
0x000083b4 <+64>: andeq r8, r0, r8, lsl #9
End of assembler dump.
(gdb) quit
可以看出,if指令被编译成了3条指令,即cmp, movle, movgt分别用以条件赋值。其中,mov为基础指令,后方跟着的le表示小于等于,gt表示大于。
3. 设计 C 的代码场景,观察是否产生了寄存器移位寻址
root@Acadia:~/tmp/shift# cat shift.c
#include
int main(){
int a;
scanf("%d", &a);
a = a * 9;
printf("%d\n", a);
return 0;
}
root@Acadia:~/tmp/shift# gcc shift.c -marm -O2 -o shift.out
root@Acadia:~/tmp/shift# echo disas main | gdb shift.out > shift_main.s
root@Acadia:~/tmp/shift# cat shift_main.s
...
(gdb) Dump of assembler code for function main:
0x00008374 <+0>: push {lr} ; (str lr, [sp, #-4]!)
0x00008378 <+4>: sub sp, sp, #12
0x0000837c <+8>: add r1, sp, #4
0x00008380 <+12>: movw r0, #33920 ; 0x8480
0x00008384 <+16>: movt r0, #0
0x00008388 <+20>: bl 0x835c <__isoc99_scanf>
0x0000838c <+24>: ldr r2, [sp, #4]
0x00008390 <+28>: mov r0, #1
0x00008394 <+32>: movw r1, #33924 ; 0x8484
0x00008398 <+36>: movt r1, #0
0x0000839c <+40>: add r2, r2, r2, lsl #3 ; a = a * 9 = a << 3 + a
0x000083a0 <+44>: str r2, [sp, #4]
0x000083a4 <+48>: bl 0x8350 <__printf_chk>
0x000083a8 <+52>: mov r0, #0
0x000083ac <+56>: add sp, sp, #12
0x000083b0 <+60>: pop {pc}
由于9在二进制中表示为1001,所以使用移位加法的方式能够很好得避过消耗较大的乘法操作。此时add中使用的就是寄存器移位寻址功能。
4. 设计 C 的代码场景,观察一个复杂的 32 位数是如何装载到寄存器的
root@Acadia:~/tmp/loadword# cat loadword.c
#include
int main(){
int a = 2123456789;
printf("%d\n", a);
return 0;
}
root@Acadia:~/tmp/loadword# gcc loadword.c -O2 -marm -o loadword.out
root@Acadia:~/tmp/loadword# echo disas main | gdb loadword.out > loadword_main.s
root@Acadia:~/tmp/loadword# cat loadword_main.s
...
(gdb) Dump of assembler code for function main:
0x00008320 <+0>: push {r3, lr}
0x00008324 <+4>: mov r0, #1
0x00008328 <+8>: movw r1, #33808 ; 0x8410
0x0000832c <+12>: movw r2, #24853 ; 0x6115 ; r2 = 0x00006115
0x00008330 <+16>: movt r1, #0
0x00008334 <+20>: movt r2, #32401 ; 0x7e91 ; r2 = 0x7e916115
0x00008338 <+24>: bl 0x8308 <__printf_chk>
0x0000833c <+28>: mov r0, #0
0x00008340 <+32>: pop {r3, pc}
使用计算器,可以求得2123456789 = 0x7E916115。所以,代码中的movw与movt分别将低位与高位载入寄存器中进行运算。而r1的movw主要是提供了printf的第一个操作数即格式字符串的位置。
5. 写一个 C 的多重函数调用的程序,观察和分析
- 调用时的返回地址在哪里?
- 传入的参数在哪里?
- 本地变量的堆栈分配是如何做的?
- 寄存器是 caller 保存还是 callee 保存?是全体保存还是部分保存?
root@Acadia:~/tmp/func# cat func.c
#include
int fibo(int n){
if (n <= 1) return 1;
return fibo(n-2) + fibo(n-1);
}
int main(){
int a;
scanf("%d", &a);
a = fibo(a);
printf("%d\n", a);
return 0;
}
root@Acadia:~/tmp/func# vim func.c
root@Acadia:~/tmp/func# gcc func.c -O2 -marm -o func.out
...
root@Acadia:~/tmp/func# echo disas main | gdb func.out > func_main.s
root@Acadia:~/tmp/func# echo disas fibo | gdb func.out > func_fibo.s
root@Acadia:~/tmp/func# cat func_fibo.s
...
(gdb) Dump of assembler code for function fibo:
0x00008434 <+0>: cmp r0, #1
0x00008438 <+4>: push {r3, r4, r5, lr}
0x0000843c <+8>: ble 0x8468 ; n <= 1 直接退出并返回1
0x00008440 <+12>: sub r4, r0, #2
0x00008444 <+16>: mov r5, #0
0x00008448 <+20>: mov r0, r4
0x0000844c <+24>: sub r4, r4, #1 ; r4 = r0 - 1 即 r4 = n - 1
0x00008450 <+28>: bl 0x8434
0x00008454 <+32>: cmn r4, #1 ; r4 -= 1, 即r4 = n - 2
0x00008458 <+36>: add r5, r5, r0
0x0000845c <+40>: bne 0x8448 ; 尾递归优化
0x00008460 <+44>: add r0, r5, #1
0x00008464 <+48>: pop {r3, r4, r5, pc}
0x00008468 <+52>: mov r0, #1
0x0000846c <+56>: pop {r3, r4, r5, pc}
root@Acadia:~/tmp/func# cat func_main.s
...
(gdb) Dump of assembler code for function main:
0x00008374 <+0>: push {lr} ; (str lr, [sp, #-4]!)
0x00008378 <+4>: sub sp, sp, #12
0x0000837c <+8>: add r1, sp, #4
0x00008380 <+12>: movw r0, #33988 ; 0x84c4
0x00008384 <+16>: movt r0, #0
0x00008388 <+20>: bl 0x835c <__isoc99_scanf>
0x0000838c <+24>: ldr r0, [sp, #4]
0x00008390 <+28>: bl 0x8434 ; 调用fibo函数
0x00008394 <+32>: movw r1, #33992 ; 0x84c8
0x00008398 <+36>: movt r1, #0
0x0000839c <+40>: mov r3, r0
0x000083a0 <+44>: mov r0, #1
0x000083a4 <+48>: mov r2, r3
0x000083a8 <+52>: str r3, [sp, #4]
0x000083ac <+56>: bl 0x8350 <__printf_chk>
0x000083b0 <+60>: mov r0, #0
0x000083b4 <+64>: add sp, sp, #12
0x000083b8 <+68>: pop {pc}
End of assembler dump.
(gdb) quit
a. 程序的返回地址在调用的时候默认存入lr寄存器,而在函数内为了保证返回结果的正确性,将其push进堆栈中。而后在函数结束的时候,将其pop至pc寄存器实现跳转返回。
b. 从fibo函数中看出,r0为函数的第一个参数。而在参数少于4个的时候,使用寄存器r0~r4传递参数。
c. 本地变量的堆栈分配使用push操作将原本的寄存器值存在堆栈中,当返回时再pop出来。而从 push {lr} === str lr, [sp, #-4]可以看出,堆栈是自顶向下伸展的。
d. 寄存器的值是collee保存。因为外部调用者不知道内部函数所需要的寄存器,保存也就无从谈起。
而函数内部用到的所有寄存器均会被保存,因为函数内部并不知道外部会使用哪些寄存器。
6. MLA 是带累加的乘法,尝试要如何写 C 的表达式能编译得到 MLA 指令。
root@Acadia:~/tmp/mla# cat mla.c
#include
int main(){
int a, tot;
scanf("%d %d", &a, &tot);
tot += a * a;
printf("%d\n", tot);
return 0;
}
root@Acadia:~/tmp/mla# gcc mla.c -marm -O2 -o mla.out
...
root@Acadia:~/tmp/mla# echo disas main | gdb mla.out > mla_main.s
root@Acadia:~/tmp/mla# cat mla_main.s
...
(gdb) Dump of assembler code for function main:
0x00008374 <+0>: push {lr} ; (str lr, [sp, #-4]!)
0x00008378 <+4>: sub sp, sp, #12
0x0000837c <+8>: add r2, sp, #4
0x00008380 <+12>: movw r0, #33932 ; 0x848c
0x00008384 <+16>: mov r1, sp
0x00008388 <+20>: movt r0, #0
0x0000838c <+24>: bl 0x835c <__isoc99_scanf>
0x00008390 <+28>: ldr r2, [sp, #4]
0x00008394 <+32>: ldr r3, [sp]
0x00008398 <+36>: mov r0, #1
0x0000839c <+40>: movw r1, #33940 ; 0x8494
0x000083a0 <+44>: movt r1, #0
0x000083a4 <+48>: mla r3, r3, r3, r2 ; tot += a * a => r3 = r3 * r3 + r2
0x000083a8 <+52>: mov r2, r3
0x000083ac <+56>: str r3, [sp, #4]
0x000083b0 <+60>: bl 0x8350 <__printf_chk>
0x000083b4 <+64>: mov r0, #0
0x000083b8 <+68>: add sp, sp, #12
0x000083bc <+72>: pop {pc}
End of assembler dump.
(gdb) quit
值得一提的是,在不使用-O2选项的情况下,编译的结果并不含mla。由此可见,代码优化的一种方式是将程序指令尽可能的贴合CPU,手动编写功能的运行效率较低。
7. BIC是对某一个比特清零的指令,尝试要如何写 C 的表达式能编译得到 BIC 指令。
root@Acadia:~/tmp/bic# cat bic.c
#include
#include
int main(){
int a, b;
scanf("%d %d", &a, &b);
a &= ~b;
printf("%d\n", a);
return 0;
}
root@Acadia:~/tmp/bic# gcc bic.c -marm -O2 -o bic.out
...
root@Acadia:~/tmp/bic# echo disas main | gdb bic.out > bic_main.s
root@Acadia:~/tmp/bic# cat bic_main.s
...
(gdb) Dump of assembler code for function main:
0x00008374 <+0>: push {lr} ; (str lr, [sp, #-4]!)
0x00008378 <+4>: sub sp, sp, #12
0x0000837c <+8>: add r2, sp, #4
0x00008380 <+12>: movw r0, #33928 ; 0x8488
0x00008384 <+16>: mov r1, sp
0x00008388 <+20>: movt r0, #0
0x0000838c <+24>: bl 0x835c <__isoc99_scanf>
0x00008390 <+28>: ldr r3, [sp]
0x00008394 <+32>: ldr r2, [sp, #4]
0x00008398 <+36>: mov r0, #1
0x0000839c <+40>: movw r1, #33936 ; 0x8490
0x000083a0 <+44>: movt r1, #0
0x000083a4 <+48>: bic r2, r3, r2 ; a &= ~b
0x000083a8 <+52>: str r2, [sp]
0x000083ac <+56>: bl 0x8350 <__printf_chk>
0x000083b0 <+60>: mov r0, #0
0x000083b4 <+64>: add sp, sp, #12
0x000083b8 <+68>: pop {pc}
End of assembler dump.
(gdb) quit
bic指令将某些由标示数指定的bit清零,其原理是通过将标示数进行取反后取and,即所有原本在标示数中为1的位,经过取反之后为0。通过and操作,将所有的0位覆盖至目标,同时却又不覆盖其他bit的值。
8. 编写一个汇编函数。
编写要求:接受一个整数和一个指针做为输入,指针所指应为一个字符串,该汇编函数调用C语言的 printf()函数输出这个字符串的前n个字符,n即为那个整数。在C语言写的main()函数中调用并传递参数给这个汇编函数 来得到输出。
root@Acadia:~/tmp/asm# cat cutprint.S
.global cutprint
cutprint:
push {R5, R6, R7, lr}
MOV R5, R0
MOV R6, R1
MOV R7, #0 ; 首先,将r0, r1保存起来,同时将计数器r7置零
CMP R7, R6
BGE exit ; 如果r7大于r6的话,直接返回,因为r6必定是负数
begin:
LDR R0, =char ; 由于函数返回值在r0的位置,所以每次调用玩函数,"%c"就会被覆盖,需要再载入一次
LDR R1, [R5, R7] ; 根据字符串以及r7计数器的下标,载入字符
CMP R1, #0 ; 如果遇到了c风格字符串结尾'\0',直接跳出循环
BEQ exit
bl printf
ADD R7, R7, #1 ; 计数器+1
CMP R7, R6
BLT begin ;判断是否循环结束,未结束则跳到begin进行下一轮循环
exit:
LDR R0, =newline ; 输出结束换行
bl printf
MOV R0, R7 ; 把输出的计数器作为返回值
pop {R5, R6, R7, pc}
.data
char: .asciz "%c"
newline: .asciz "\n"
root@Acadia:~/tmp/asm# cat cutprint.c
#include
extern int cutprint(char*, int);
int main(){
int a;
char s[100];
scanf("%s %d", s, &a);
a = cutprint(s, a);
printf("Print %d character.\n", a);
return 0;
}
root@Acadia:~/tmp/asm# gcc cutprint.c cutprint.S -o cutprint -g -marm
root@Acadia:~/tmp/asm# ./cutprint
123456789 -1
Print 0 character.
root@Acadia:~/tmp/asm# ./cutprint
123456789 5
12345
Print 5 character.
root@Acadia:~/tmp/asm# ./cutprint
123456789 19999
123456789
Print 9 character.
其中,cutpinrt.s的程序逻辑类似如下:
int cutprint(cahr *s, int a){
int i = 0;
for (i; i
9. 编写测试程序,测试ARM指令和Thumb指令的执行效率
root@Acadia:~/tmp/speed# cat speed.c
#include
int fibo(int n){
if (n <= 1) return 1;
return fibo(n - 2) + fibo(n - 1);
}
int main(){
int a;
scanf("%d", &a);
printf("%d\n", fibo(a));
return 0;
}
root@Acadia:~/tmp/speed# gcc speed.c -marm -o arm.out
root@Acadia:~/tmp/speed# gcc speed.c -mthumb -o thumb.out
root@Acadia:~/tmp/speed# echo 40 > speed.in
root@Acadia:~/tmp/speed# time ./arm.out < speed.in
165580141
real 0m5.951s
user 0m5.940s
sys 0m0.000s
root@Acadia:~/tmp/speed# time ./thumb.out < speed.in
165580141
real 0m6.425s
user 0m6.400s
sys 0m0.010s
root@Acadia:~/tmp/speed# gcc speed.c -marm -o arm.out -O2
...
root@Acadia:~/tmp/speed# gcc speed.c -mthumb -o thumb.out -O2
...
root@Acadia:~/tmp/speed# time ./arm.out < speed.in
165580141
real 0m2.190s
user 0m2.180s
sys 0m0.000s
root@Acadia:~/tmp/speed# time ./thumb.out < speed.in
165580141
real 0m3.988s
user 0m3.990s
sys 0m0.000s
root@Acadia:~/tmp/speed# gcc speed.c -marm -o arm.out -O3
...
root@Acadia:~/tmp/speed# gcc speed.c -mthumb -o thumb.out -O3
...
root@Acadia:~/tmp/speed# time ./arm.out < speed.in
165580141
real 0m2.520s
user 0m2.510s
sys 0m0.000s
root@Acadia:~/tmp/speed# time ./thumb.out < speed.in
165580141
real 0m2.723s
user 0m2.710s
sys 0m0.010s
程序中,主要消耗时间的地方在于fibo(40)的递归调用,可以看出,thumb整体而言还是偏慢的,但是如果开了编译优化之后,差别不会过大。
10. 编写测试程序,测试使用带条件的ARM指令和不使用时的执行效率。
root@Acadia:~/tmp/ifspeed# cat noif.S
.global add
add:
CMP R0, #0
ADD R0, R0, R1
MOV pc, lr
root@Acadia:~/tmp/ifspeed# cat useif.S
.global add
add:
CMP R0, #0
ADDNE R0, R0, R1
MOV pc, lr
root@Acadia:~/tmp/ifspeed# cat test.c
#include
extern int add(int, int);
int main(){
int a, b, i, times;
scanf("%d %d %d", &a, &b, ×);
for (i=0; i
经过多次比对,虽然时间略有波动。但是总体而言,两程序执行时间基本没有区别,即ADDNE与ADD的执行基本没有时间差。
参考资料
- ARM指令和Thumb指令的区别
- GCC的ARM编译选项
- ARM指令如何在thumb和arm模式切换
- ARM指令集详解
- arm c函数的调用过程arm汇编语言调用C函数之参数传递
- Hello World in ARM Assembly Language