TCC,全称Tiny C Compiler(http://bellard.org/tcc/),是一个颇具特色的C编译器,你能把它当作一个C语言解释器来用,也可以嵌入你自己的应用程序作一个动态代码生成器。是的,我们就是这么干的。在我们的项目中,粒子系统的运动规则用C语言来描述,然后由TCC动态生成native code运行。这么做既不失效率又保持了较高的动态能力。
但是,既然是使用第三方库,那就要准备好享受成果的同时吞下bug。这一次,我们吃到的可是一只非常揪心的虫子。
众所周知,X86 CPU的浮点计算单元(FPU)共有8个浮点数寄存器,它们是按照栈的形式组织的。如果load浮点数进了一个寄存器,那么它就属于被占用的,需要用类似pop的操作把它释放掉后才能重新使用。
对于TCC来说,一个函数如果使用了浮点运算,那么它生成的代码在函数返回的时候会在FPU栈上留下一个垃圾(为什么?
这是后话,也是本文的主旨。),这样8个寄存器就只剩下7个可以用了。如果你的程序全部用TCC编译这没什么问题,但是和gcc或者msvc混合使用的话就有问题了。因为这些编译器一直认为在刚刚进入任何一个函数的时候都会有8个浮点寄存器可用,而如果打开了优化开关的话,它们就有可能生成一些很牛B的代码,一下子把8个寄存器全都用满。这就糟糕了,只有7个茅坑(另外一个TCC占着不拉屎),一下子要蹲8个,于是就触发了FPU内部的“茅坑使用异常”(学名叫FPU invalid operation
exception:#IE)。关键是这个鸟异常一般情况下是被FPU罩(mask)着的,我们根本不知道,以为天下太平,但是从浮点寄存器
上取回的值那就错得象一坨屎一样了。
这个bug折磨了我们好几天,同事云风已经说过这事情了。接下来我要带大家掘地三尺,找准位置,痛下杀脚,踩死这只臭虫(看丫还蹦达)。
我们的分析标本是tcc-0.9.25,也是目前最新的官方发行版,源码这里下:http://download.savannah.nongnu.org/releases/tinycc/tcc-0.9.25.tar.bz2。
TCC通过`fstp %st(1)'指令在FPU栈上留垃圾。st是FPU stack的简写,st(n)指的是浮点栈的第n号寄存器,栈顶到栈底依次按0、1、2...进行编号。这句指令的意思是不管st(1)有没有被占用,都把st(0)的内容拷贝到st(1),然后释放st(0)即栈顶,原来的st(1)成为新的栈顶。该指令结束后,FPU栈顶一定是被占用的。
有两个地方会生成`fstp %st(1)'指令(二进制编码是0xd9dd):tccgen.c的689行(vpop函数内);tccgen.c的210行(save_reg函数内)。我们首先把它们都改为生成`fstp %st(0)'指令(二进制编码是0xd8dd)。`fstp %st(0)'的意思其实就是弹出FPU栈顶寄存器的内容,使st(0)成为未占用状态,不做任何多余的事。本来vpop和save_reg这两个函数就是为了释放寄存器才生成相关指令的,这么一改就合乎函数的原本意图了。
那是不是这样就万事大吉了呢?显然是不够的,如果真这么简单我用得着写这篇文章吗?让我们试着用修改过的TCC编译下面的函数:
void foo()
{
double var = 2.7;
var++;
}
它会生成这样的机器码:
.text:08000000 public foo
.text:08000000 foo proc near
.text:08000000