上节介绍了简单的加减乘除运算,在除法div运算时,被除数存放在寄存器rdx:rax中,执行除法后,商存放在rax中,余数存放在rdx中;乘法mul运算中,一个乘数存放在rax中,乘法运算的结果存放在rdx:rax中。
这节介绍循环,以一个冒泡排序的例子:b_sort.asm
; b_sort.asm
; bubble sort
; nasm -f elf64 -o b_sort.o b_sort.asm
; gcc -o b_sort b_sort.o
; ./b_sort
extern printf
section .data
array: dq 5,4,3,2,1
fmt: db '%ld',0xa,0
section .text
global main
main:
push rbp
mov rsi, 0
mov rdi, 0
L1:
cmp rsi, 5
jge L1_END
mov rdi, rsi
inc rdi
L2:
cmp rdi, 5
jge L2_END
mov rax, [array+rsi*8]
mov rbx, [array+rdi*8]
cmp rax, rbx
jg exchange
inc rdi
jmp L2
exchange:
mov [array+rdi*8], rax
mov [array+rsi*8], rbx
inc rdi
jmp L2
L2_END:
inc rsi
jmp L1
L1_END:
mov rbx, 0
L3:
cmp rbx, 5
jge L3_END
mov rdi, fmt
mov rsi, [array+rbx*8]
mov rax, 0
call printf
inc rbx
jmp L3
L3_END:
pop rbp
mov rax, 60
syscall
ret
数据段.data中,定义了数组array,还有一个输出格式的字符串fmt。
push rbp
mov rsi, 0
mov rdi, 0
程序的开始,将rbp压入堆栈,这是为了不破坏rbp,然后将寄存器rsi和rdi设置为0,这是双重循环的索引,类似C语言中的i,j。如下面的C代码所示,rsi相当于外层循环的i,rdi相当于内层循环的j,这里将rdi设置为0没有必要,在内层循环开始之前会对rdi赋值。
int i,j;
for( i = 0; i < 5; i++ )
{
for( j = i+1; j < 5; j++)
{
...
}
}
L1:
cmp rsi, 5
jge L1_END
mov rdi, rsi
inc rdi
L1表示外层循环的标签开始处,首先将rsi与5进行比较,当rsi>=5时,循环结束,跳出L1循环。然后将rdi设置为rsi+1。
L2:
cmp rdi, 5
jge L2_END
mov rax, [array+rsi*8]
mov rbx, [array+rdi*8]
cmp rax, rbx
jg exchange
inc rdi
jmp L2
L2是内层循环的标签开始处,同样将rdi与5比较,当rdi>=5时,循环结束,跳出L2循环。然后将数组array中的第rsi个数和第rdi个数赋值给rax和rbx,因为array中每个整数的字节大小为8,因此在取值的时候,将rsi和rdi乘以8.
之后,比较rax和rbx,当rax大于rbx时,跳转到exchange标签,来交换数组中的两个整数,否则,将rdi加1,跳转到L2继续循环执行内层循环。
exchange:
mov [array+rdi*8], rax
mov [array+rsi*8], rbx
inc rdi
jmp L2
L2_END:
inc rsi
jmp L1
L1_END:
exchange标签执行交换操作,然后将rdi加1,跳转到L2执行;当L2内层循环结束后,将外层循环的索引rsi加1,跳转到L1执行。
mov rbx, 0
L3:
cmp rbx, 5
jge L3_END
mov rdi, fmt
mov rsi, [array+rbx*8]
mov rax, 0
call printf
inc rbx
jmp L3
L3_END:
然后是一个单层的循环操作来输出array数组。
但是,编译器是这样来将循环代码生成汇编代码的吗?我们编写一个C测试程序,从中看看编译器是如何处理循环的。
; ubuntu 64
; a.c
; gcc -o a.out a.c
#include <stdio.h>
int main()
{
int i;
for(i = 0; i < 3; i++)
printf("%d ",i);
return 0;
}
上面是一个简单的单重循环,使用objdump -d a.out命令来反汇编,部分反汇编结果如下:
0000000000400536 <main>:
400536: 55 push %rbp
400537: 48 89 e5 mov %rsp,%rbp
40053a: 48 83 ec 10 sub $0x10,%rsp ; 为局部变量申请栈空间
40053e: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) ; i = 0
400545: eb 18 jmp 40055f <main+0x29>
400547: 8b 45 fc mov -0x4(%rbp),%eax ; eax = i
40054a: 89 c6 mov %eax,%esi
40054c: bf f4 05 40 00 mov $0x4005f4,%edi
400551: b8 00 00 00 00 mov $0x0,%eax
400556: e8 b5 fe ff ff callq 400410 <printf@plt> ; printf
40055b: 83 45 fc 01 addl $0x1,-0x4(%rbp) ; i = i+1
40055f: 83 7d fc 02 cmpl $0x2,-0x4(%rbp) ; i <= 2?
400563: 7e e2 jle 400547 <main+0x11>
400565: b8 00 00 00 00 mov $0x0,%eax
40056a: c9 leaveq
40056b: c3 retq
40056c: 0f 1f 40 00 nopl 0x0(%rax)
我在上面的代码中加了注释,sub指令为局部变量申请栈空间,我们看到rbp-4处存放i,然后程序跳到了40055f,这里将i与2比较,如果小于等于2,则跳到400547处执行,这就是gcc编译器在对单重循环处理的结果。
设置初值i=0
jmp judge
content:
...
循环内容
...
i = i + 1
judge:
i是否小于等于2
如果是:jmp L2
循环的判断在循环内容的后面,如果需要继续进行循环,则往上跳转。
这是单重循环,如果是双重循环呢?会更复杂,我们来看一看。
; ubuntu 64
; b.c
; gcc -o b.out b.c
#include <stdio.h>
int main()
{
int i,j;
for(i = 0; i < 3; i++)
{
for( j = 0; j < 5; j++)
printf("%d ", i+j);
}
return 0;
}
上面是个双重循环,同样使用objdump -d b.out来反汇编代码,部分反汇编代码如下:
0000000000400536 <main>:
400536: 55 push %rbp
400537: 48 89 e5 mov %rsp,%rbp
40053a: 48 83 ec 10 sub $0x10,%rsp
40053e: c7 45 f8 00 00 00 00 movl $0x0,-0x8(%rbp) ; i = 0
400545: eb 30 jmp 400577 <main+0x41>
400547: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) ; j = 0
40054e: eb 1d jmp 40056d <main+0x37>
400550: 8b 55 f8 mov -0x8(%rbp),%edx
400553: 8b 45 fc mov -0x4(%rbp),%eax
400556: 01 d0 add %edx,%eax
400558: 89 c6 mov %eax,%esi
40055a: bf 14 06 40 00 mov $0x400614,%edi
40055f: b8 00 00 00 00 mov $0x0,%eax
400564: e8 a7 fe ff ff callq 400410 <printf@plt> ; printf
400569: 83 45 fc 01 addl $0x1,-0x4(%rbp) ; j = j+1
40056d: 83 7d fc 04 cmpl $0x4,-0x4(%rbp) ; j <= 4?
400571: 7e dd jle 400550 <main+0x1a>
400573: 83 45 f8 01 addl $0x1,-0x8(%rbp) ; i = i+1
400577: 83 7d f8 02 cmpl $0x2,-0x8(%rbp) ; i <= 2?
40057b: 7e ca jle 400547 <main+0x11>
40057d: b8 00 00 00 00 mov $0x0,%eax
400582: c9 leaveq
400583: c3 retq
400584: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
40058b: 00 00 00
40058e: 66 90 xchg %ax,%ax
从上面的代码中,我们也可以看出,判断循环的边界代码位于循环内容的后面,首先是将j加1,判断内层循环是否结束,如果内层循环结束,然后判断外层循环。我们用下面的示意图来描述上面的代码:
设置初始值i=0
jmp judge2
L1:
设置初始值j=0
jmp judge1
content:
...
循环内容
...
j = j + 1
judge1:
j小于等于4?
如果是,jmp content
i = i + 1
judge2:
i小于等于2?
如果是,jmp L1
设置完i的初始值后,跳转到judge2处,判断外层循环是否结束,没结束,就跳转到内层循环,设置初始值j,跳转到内层循环的判断judge1,如果循环没结束,则跳转到循环内容。可以总结如下:
设置外层循环初始值
跳转到外层循环判断处
设置内层循环初始值
跳转到内层循环判断处
循环内容
内层循环索引+1
内层循环判断处
外层循环索引+1
外层循环判断处
这样做,结构更加清晰,因此,我们在编写循环的汇编代码时,可以参考使用这样的结构。
编译器为什么要这样组织循环结构呢?其实我们可以很容易猜得出,这样做,执行效率较高。
大家可以看看编译器如何处理三重循环。