gcc和clang编译器处理前置自增表达式的区别

本文最早发布于我的知乎回答:https://www.zhihu.com/question/39590451/answer/288933259
今天刚好有学弟学妹来问我类似的问题,就借着这个问题回答一下: 基本环境:Linux下的gcc和clang(没看版本,应该是最新)
先附上源程序

#include <stdio.h>
int main(int argc, char *argv[]) {
    int sum,i=2;
    sum=(++i)+(++i)+(++i)+(++i);
    printf("%d %d\n",sum,i);
    return 0;
}


是和题主一样的问题,使用gcc编译该程序:

gcc -g -o test-gcc test.c

得到可执行程序test-gcc,执行后输出

19 6

使用clang编译该程序:

clang -g -o test-clang test.c

clang提示警告:

test.c:4:7: warning: multiple unsequenced modifications to 'i' [-Wunsequenced]
        sum=(++i)+(++i)+(++i)+(++i);
             ^     ~~
1 warning generated.

得到可执行程序test-clang,执行后输出

18 6

看完了现象,那么本质原因如何呢?我们借助IDA逆向分析工具来观察test-gcc和test-clang这两个可执行程序,我使用的是IDA Pro 7.0的macOS版本,还不知道IDA Pro是什么的同学可以百度搜一搜,会得到答案。
我们首先分析“结果正常”的test-clang,将test-clang导入进IDA Pro 7.0 64-bit,定位到关键汇编代码(我添加了注释):

;子程序开始(主函数开始)
main proc near
;定义了四个双字变量,因为是64位系统,所以这些变量都是8个字节的
var_10= dword ptr -10h
var_C= dword ptr -0Ch
var_8= dword ptr -8
var_4= dword ptr -4
;程序初始化
push rbp
mov rbp,rsp
sub rsp,10h
;进行printf的格式化参数初始化,可以忽略
mov rdi,offset format ; "%d %d\n"
;将var_4变量赋值为0,var_C变量赋值为2
mov [rbp+var_4],0
mov [rbp+var_C],2
;将var_C加1,这里要借助寄存器eax来加
;eax中的值现在是3,var_C=3
mov eax,[rbp+var_C]
add eax,1
mov [rbp+var_C],eax
;将var_C再加1,借助了另一个寄存器ecx来加
;ecx中的值现在是4,var_C=4
mov ecx,[rbp+var_C]
add ecx,1
mov [rbp+var_C],ecx
;eax和ecx现在相加了,结果送入eax
;eax中的值为3+4=7
add eax,ecx
;将var_C再加1,借助了寄存器ecx来加
;ecx中的值现在为5,var_C=5
mov ecx,[rbp+var_C]
add ecx,1
mov [rbp+var_C],ecx
;再将上面用到的eax加上了ecx
;现在eax中的值为7+5=12
add eax,ecx
;将var_C再加1,借助了寄存器ecx来加
;ecx中的值现在为6,var_C=6
mov ecx,[rbp+var_C]
add ecx,1
mov [rbp+var_C],ecx
;再将上面用到的eax加上了ecx
;现在eax中的值为12+6=18
add eax,ecx
;将上面eax中的18送入变量var_8
mov [rbp+var_8],eax
;输出var_8和var_C,分别为18 6
mov esi,[rbp+var_8]
mov edx,[rbp+var_C]
mov al,0
call _printf
;用于函数返回
xor ecx,ecx
mov [rbp+var_10],eax
mov eax,ecx
add rsp,10h
pop rbp
retn
main endp

这里面clang把我们的C语言代码按题主手算的方法来编译为了汇编代码,我这里所说的变量varC等,实际访问的时候是使用的[rbp+varC],这个是汇编中的寻址方式(基址寄存器+偏移量),如果不懂的话可以略过,就理解为变量var_C即可。
我们将这段汇编代码,按照汇编流程的思维,转化为C语言代码:

#include <stdio.h>
int main(int argc, char *argv[]) {
    int var_4=0,var_C=2,var_8,eax,ecx;
    eax=++var_C;
    ecx=++var_C;
    eax+=ecx;
    ecx=++var_C;
    eax+=ecx;
    ecx=++var_C;
    eax+=ecx;
    var_8=eax;
    printf("%d %d",var_8,var_C);
    return 0;
}

结果显然是

18 6

分析完了test-clang,我们再按照同样的方式分析一下test-gcc,就会发现情况有所不同:

;子程序开始(主函数开始)
main proc near
;定义两个变量,因为使用了-g附加调试信息
;所以IDA分析出就是我们源程序中的sum和i变量
sum= dword ptr -8
i= dword ptr -4
;程序初始化
push rbp
mov rbp,rsp
sub rsp,10h
;设置变量i的值为2
mov [rbp+i],2
;变量i连续加了两次1,执行后i=4
add [rbp+i],1
add [rbp+i],1
;将变量i的值送入eax,eax为4
mov eax,[rbp+i]
;将eax+eax的所在内存内容的地址送入edx
;这句话就相当于edx=(eax+eax),edx=8
;只是编译之后变成了复杂的写法
lea edx,[eax+eax]
;变量i继续加1,执行后i=5
add [rbp+i],1
;将变量i的值送入eax,eax=5
mov eax,[rbp+i]
;eax+edx的值放入edx,edx=8+5=13
add edx,eax
;变量i继续加1,执行后i=6
add [rbp+i],1
;将变量i的值送入eax,eax=6
mov eax,[rbp+i]
;eax+edx的值放入eax,eax=6+13=19
add eax,edx
;将eax的值送入变量sum
mov [rbp+sum],eax
;调用printf输出sum和i,分别为19 6
mov edx,[rbp+i]
mov eax,[rbp+sum]
mov esi,eax
mov edi,offset format ; "%d %d\n"
mov eax,0
call _printf
;程序返回
mov eax,0
leave
retn
main endp

可以看出来gcc的编译思路比较清奇,所以才导致了意想不到的结果,我们同样将汇编语言的代码,按照汇编语言的思维,转换为C语言代码:

#include <stdio.h>
int main(int argc, char *argv[]) {
    int sum,i,eax,edx;
    i=2;
    ++i;
    ++i;
    eax=i;
    edx=eax+eax;
    eax=++i;
    edx+=eax;
    eax=++i;
    eax+=edx;
    sum=eax;
    printf("%d %d",sum,i);
    return 0;
}

结果显然是:

19 6

至此我们找到了不同编译器运行结果不同的原因,是因为gcc和clang在编译这同一段C语言代码的时候,把他们按照不同的思路转化为了汇编代码,所以执行结果才不同,显然clang的转化方式更能符合正常人的思维,所以现在更推荐使用clang,用clang来代替gcc。