记一次x87 FPU寄存器栈溢出

工作中遇到项目贴的一个JIRA ticket,说是地图渲染的道路有异常色块:

 

接着花了大半天时间在VS各种窗口中奋战,踩了无数坑后,最终结论是x87 FPU寄存器栈溢出引起的,很可能是MSVC编译器bug。

(即使不是编译器的bug,调试过程也颇为值得记录一下)

虽然最终定位到起因就一行汇编,但是为了找到这一行非有悬梁刺股的决心不可。

1、复现

尽管是稳定复现的bug,但是必须链接Jenkins自动编译的库才能复现,我本地编译的库不管怎么同步CMake里面那些flag,什么/O2, /Ob2, /MP啦,都不行。

不得已只能加个/Zi把符号表弄进去,至少还能设个断点嘛。

复现规律还有另一个特点:只在PC模拟器复现,真机设备上不复现。(这一点很重要)

种种迹象表明,这bug绝非善类。

2、NAN

直觉告诉我是坐标异常引起的,于是把问题瓦片对应道路的坐标都打印了一下:

 

可整整有三千多行,还好我眼尖一下子发现了那个-1.#IND00,嗯,这是一个NAN(not a number),看来就是他了。其他还有好几处,每一处应该就对应着一段色块。

稳妥起见,再对比了一下本地正常运行的打印结果,确实没有那些NAN。

3、拦截

写了段代码用断点拦截出现NAN的时机,很简单,一个for循环就可以。

唯一需要注意的是判断x是否NAN需要用x!=x,而不能傻傻地写x==NAN。这又是IEEE 754的一个dark corner。

之后就是漫长的定位范围缩小再缩小……

4、条件断点

最终利用VS的条件断点找到了最小复现单元:

MiterJoint_calculate()执行之后,输出的joint.p6.y变成了NAN,其他一切正常。joint是一个局部变量,像是内存的堆栈被写坏了。

另外,这个是一个无任何全局数据依赖的函数,奇怪的是,当我在其他地方独立地用同样的输入调用该函数时,却复现不了了。

5、内存断点

跟踪进入函数体,发现joint.p6被优化掉了(不出所料)。

还好有内存断点这样的终极武器:

果然没有让我失望:

6、汇编

(奈何最终还是要走到这一步...)

Alt+8切到汇编:

看到这些f开头的指令,突然明白为什么我自己的编的库不能复现了:指令集和Jenkins自动构建的不一样,忘了加/arch:IA32,默认用了SSE2而不是x87.

这些f开头的指令属于Intel x87 FPU指令集,在现今SIMD横行的年代已经属于老古董,它的80位long double一般人也用不到。

fstp的意思是将一个浮点数从寄存器栈(是的,x87设计了一个大小仅为8的浮点数寄存器栈!)弹出并存进内存。

关于x87寄存器的说明参见:

Simply FPU Chap.1 (masmforum.com)

在fstp处设断点,观察floating point寄存器内容:

寄存器栈顶ST0确实是NAN。

接下来又重跑了一次,找到了产生这个NAN产生的罪魁祸首:

00FA7192  fld         st(0)  

这条fld指令(FLD — Load Floating Point Value (felixcloutier.com))是将st(0)寄存器的内容读取,并压入寄存器栈。因为st(0)本身就是栈顶,因此期望结果是st(0)重复了两次。

这条指令之前的寄存器:

执行之后却是这样的:

ST1及之后的没有问题,确实因为压栈被偏移了一个单元。

ST0却出现了NAN!

7、stack overflow

经过漫长的搜索之后,终于找到一位老哥遇到类似的问题,而且踩的坑比我惨多了:

Everything Old is New Again, and a Compiler Bug | Random ASCII – tech blog of Bruce Dawson (wordpress.com)

里面指出了寄存器栈溢出这一现象。

为了验证发生了栈溢出,除了利用文中提到的修改CTRL寄存器为027E之外,还有两个更直接的办法:

1)STAT寄存器

STAT是一个16位的状态寄存器,每条指令执行后相关的一些标志位会被设置。

注意到FLD文档中提到:

可以通过STAT中的C1标志位查看是否发生了栈溢出(从低位起第9位):

0x036D == 0000_0011_0110_1101(b)

确实是1。

2)TAGS寄存器

TAGS也是一个16位寄存器,直接显示8个浮点数寄存器的状态,每个用两比特表示,具体含义可以参见以上文档。

执行指令前的TAGS:

0x0002 == 0000_0000_0000_0010b

其中7个浮点数寄存器状态是00(正常值),另外一个是10(NAN),总之8个寄存器没有空的,栈已满。接下来的fld指令自然就溢出了。

 

多年以来遇到过各种内存栈溢出,FPU寄存器栈溢出还是第一次遇见!

推测极有可能是MSVC生成汇编代码的bug。

 

回顾最初的复现现象,一切都已经豁然开朗:

为什么必须是PC才能复现?-因为只有PC上才有x87这种老古董

为什么必须开O2才能复现?-优化过猛容易翻车

为什么不能独立复现?-FPU寄存器栈是有历史的,不卡到那个点就不会溢出

 

和大多数难查的bug一样,修复异常简单:将CMakeLists.txt中的/arch:IA32删掉。

posted @ 2021-02-25 22:37  Xrst  阅读(511)  评论(0编辑  收藏  举报