由使用未定义变量引出的gcc编译器优化初探
起因
有人问我一道题为什么在本地运行时对的,但提交到评测机却异常了。我的第一反应就是变量初始化可能有问题,而编译选项的不同导致了不同行为。
经过仔细研究,发现一个有趣的现象,遂在此分享一下。
代码类似于这样的逻辑(各种变量的调用关系跟题目里是一样的,这里做了个最简抽象):
#include<stdio.h>
int test(){
int a,b;
scanf("%d",&b);
if(b>0){
a=-1;
}else{
// do sth
// 但是这里忘记对a变量进行任何赋值操作了,即使用了未定义的变量
}
return a;
}
int main(){
int a = test();
printf("%d\n",a);
return 0;
}
我用 printf debug大法
#include<stdio.h>
int test(){
int a,b;
scanf("%d",&b);
if(b>0){
a=-1;
printf("%d\n",a);
}else{
// do sth
printf("%d\n",a);
}
printf("%d\n",a);
return a;
}
int main(){
int a = test();
printf("%d\n",a);
return 0;
}
随后本地编译 gcc test.c -o test.exe
b 输入-1,输出结果是
32758
32758
32758
很明显的未定义嘛,但是恰好只要 a!=-1 程序就不会有任何问题,这就是为什么在本地运行是“正确”的
但是线上编译的命令有加优化: gcc -O2 test.c -o test.exe
b 同样输入-1,输出结果是
0
-1
-1
哦豁,不妙,未定义的情况下居然恰好返回了 a=-1, 这下程序在外面就 Error 了,这就是为什么提交之后报错。
略懂逆向的我,自然不愿意停留于此。经过测试,只要开启编译优化选项 -O 就会出现这个问题,这是为什么呢?看看汇编。(我选择了用一个在线汇编编译器来看,很方便,搜索 gobolt compiler explorer 就可以找到 ——并非广告,仅作分享),而且会用颜色标明源代码和汇编的联系。
前置汇编知识
需要了解寄存器,栈空间,函数调用时参数和返回值的传递规约,基本的汇编指令。
假如没有汇编知识,那就看看这个通俗版本(受限于本人水平可能不严谨,见谅):
原本局部变量应该给它一个内存放置的,对它的赋值和读取操作都通过那个内存。所以 a=-1 的操作会把内存的值写为-1,而没有赋值操作的情况下,那块内存原本是啥就是啥(比如上面的输出结果和返回值是32758)
但是,开启优化之后,为了节约时间,编译器选择有些局部变量的简单操作不分配栈空间了,而是直接通过代码做一些预测判断,把-1 的传参数、赋值、返回操作直接在 cpu 内实现,而不再经过内存。然而变量未定义(未赋值)的情况会导致这种预测考虑不周全,在这里就发生了这种情况。优化时编译器以为 返回值的 a 只可能等于-1(毕竟所有给 a赋值的操作都是-1),所以直接就写死了返回-1.
至于为什么在 else 里面打印出 0,大概也是编译器在这个分支内部预测之后发现 a 没有赋值过,就默认给了 0。
探究原理
没开优化之前,只看 test 函数,可以发现对变量 a 的操作都会真正地使用栈空间。所以未初始化的情况下,返回值的确是随缘的(就看那块地址本来是什么数据了)
开启优化之后,可以发现对 a 的操作不再经过栈空间,而是直接操作对应寄存器了(如下图标注)
[!note]
当 GCC(或其他现代编译器)开启优化(例如使用 -O1, -O2, -O3 等选项)后,许多局部变量确实可能不会在栈上分配实际空间,甚至可能完全“消失”。
而我们发现,return 操作对应的 .L3 那一段是被两个分支共用的。然而,编译器优化 .L3 的时候万万没有考虑到未定义的情况,直接在 return 的时候写上了 mov eax,-1,所以无论走哪个分支,都会返回-1 了,这就是问题出现的原因。