汇编语言几乎和可执行的机器语言相对应,所以如果想了解程序究竟在做什么,最好的办法就看汇编语言。这篇文章将从C语言编译出的汇编语言的层面,去看一看自增符号(++)的执行中,机器实际上发生了什么。
回顾自增符号
首先简单回顾一下C语言的自增符号,可以放在变量前或者变量后。
如果自增符号放在前面,表示变量先自增再使用,如果放在变量后面,表示先使用再自增。
举个例子,看下面两个程序:
程序1
#include <stdio.h>
int main(){
int A=0, B=0;
B = A++;
return 0;
}
该程序结束的时候,A=1,B=0,因为A++是先使用后自增。
程序2
#include <stdio.h>
int main(){
int A=0, B=0;
B = ++A;
return 0;
}
该程序结束的时候,A=1,B=1,因为++A是先自增后使用。
那么,如果是比较特殊的情况,B和A本身是同一个变量呢?
A = A++,或是A = ++A,结果会是多少?下文之后揭晓答案。
必要的汇编知识( AT&T 汇编格式)
针对新生朋友们已经做了简化,不系统讲解汇编语言知识,如果看不懂可以跳过直接看下一部分。本人汇编知识也很有限,如有讲解错误,请网友指出。
- 寄存器
简单理解为计算机中一些特殊的位置,可以存储一定大小的数据。
这里会涉及到的比较特殊的一个是rbp,大小为8字节,为当前堆栈指针(可以理解为指向程序要使用的一块空间),bp是"基址指针"(BASE POINTER)的意思,我们之后要通过这个寄存器间接访问栈空间,在栈空间上会保存我们计算用到的变量。
具体如何访问呢?看下面的例子,通过这样的方式可以给变量分配空间或者读取空间中的变量值。
%rbp 表示 栈底的位置
-4(%rbp)表示 栈底的位置-4,即指向了一个可以存储4字节大小数据的新空间
-8(%rbp)表示 栈底的位置-8,即又指向了一个可以存储4字节大小数据的新空间
- mov
mov表示把指定位置(可以是内存、寄存器等)的值传送到另一个位置。可以根据操作数的空间大小,在后面加上b(一字节)、w(两字节)、l(四字节)、q(八字节)。
例如:
movl %esp %ebp 表示把寄存器%esp中的值放到寄存器ebp中。
movl %edx, -4(%rbp) 表示把寄存器%edx的值放到-4(%rbp)的位置上。
movl $0, %eax 表示把数值0放到寄存器eax中。
- lea
lea根据括号里的源操作数来计算地址,然后把地址加载到目标寄存器中。同样,可以根据操作数的空间大小,在后面加上b(一字节)、w(两字节)、l(四字节)、q(八字节)。
举个例子:leaq a(b, c, d), %rax 先计算地址a + b + c * d,然后把最终地址载到寄存器rax中。因此,看似lea本意是操作地址,但其实往往当做加法和乘法使用。 - add
用法非常好理解,add A B就表示B=A+B。
例如:addl $1 %eax就表示在%eax的值上自增1。
自增符号使用中发生的操作
以下汇编代码仅展示相关部分。
前自增赋值给自身
程序1
#include <stdio.h>
int main(){
int A=10;
A = ++A;
}
汇编1
movl $10, -4(%rbp) // -4(%rbp) = 10
addl $1, -4(%rbp) // -4(%rbp) += 1
可以看到,编译器直接把A = ++A简化成了A+=1,这是可以理解的,因为++A是在A的值上先自增然后再使用A的值,也就是把A+1的值赋给A。
前自增赋值给其他
程序2
#include <stdio.h>
int main(){
int A=10,B=0;
B = ++A;
}
汇编2
movl $10, -4(%rbp) // -4(%rbp) = 10
movl $0, -8(%rbp) // -8(%rbp) = 0
addl $1, -4(%rbp) // -4(%rbp) += 1
movl -4(%rbp), %eax
movl %eax, -8(%rbp) // -8(%rbp) = %eax = -4(%rbp)
可以看到如果把++A赋给另一个值,和把++A赋给自己唯一多了一步就是赋值操作。在A自增完毕之后,把自增之后的值给B(对应第四行和第五行)。
后自增赋值给自身
程序3
#include <stdio.h>
int main(){
int A=10;
A = A++;
}
汇编3
movl $10, -4(%rbp) // -4(%rbp) = 10
movl -4(%rbp), %eax // %eax = -4(%rbp) (记住%eax这里等于10,是一个临时存储变量)
leal 1(%rax), %edx // %edx = 1+%rax (自增过程,%rax是%eax的高位扩展,这里值相同)
movl %edx, -4(%rbp) // -4(%rbp) = %edx (把自增后的值赋给自己,即为11)
movl %eax, -4(%rbp) // -4(%rbp) = %eax (把自增前的值赋给等号左侧的变量,恰好也是自己,值为10)
所以最后 A = 10 ,虽然是先计算了自增,但可以看到赋给其他变量(这里恰好是自身)的值是增加前的值。所以,A++虽然会在A的值上自增1,但这个表达式返回的是原始的A值,如果A = A++相当于不改变A的值。
后自增赋值给其他
程序4
#include <stdio.h>
int main(){
int A=10,B=0;
B = A++;
}
汇编4
movl $10, -4(%rbp) // -4(%rbp) = 10
movl $0, -8(%rbp) // -8(%rbp) = 0
movl -4(%rbp), %eax // %eax = -4(%rbp) (记住%eax这里等于10)
leal 1(%rax), %edx // %edx = 1+%rax (自增过程,%rax是%eax的高位扩展,这里值相同)
movl %edx, -4(%rbp) // -4(%rbp) = %edx (把自增后的值赋给自己)
movl %eax, -8(%rbp) // -8(%rbp) = %eax (把自增前的值赋给等号左侧的变量)
拿程序4和程序3对比,唯一区别就是把A++的值赋给了一个新的变量。可以看到,过程依然为A首先自增1(对应第四行和第五行),然后返回A之前的值,赋给另一个值B(对应第六行)。
总结
由此我们可以至少看出以下几点结论:
-
前自增和后自增的主要差别在于要返回一个值给其他变量时,如果不返回值给其他变量,那两种自增没有区别。
-
前自增就是先把操作数+1,然后把当前操作数的值返回出来使用,过程比较简单,全程只设计到一个寄存器的使用(不含基址指针rbp的话)。
-
后自增需要先把操作数的值放到一个寄存器上,再把操作数+1的值放到另一个寄存器上,然后分别把+1的值赋给自身,把原始的值返回使用,并且返回使用的步骤在赋值给自身之后,全程要用到两个寄存器。
这也就完美解释了,为什么A=A++不改变A的值,因为先把自增的值赋给A,这个时候A是加1的,然后把A的原始值拿来使用,此处的使用即为把A的值赋给A,这时候A的值又变回了原来的值,中间增加以及赋值给自身的两步都被覆盖掉了。
自增运算符的使用还有更多复杂情况,但都离不开这几种基本情况,所以只要搞清在汇编层面发生了什么,就可以深入理解C语言的自增运算符,正确判断任何情况下的结果。
这也体现了学习一些基本的汇编知识的重要性。