警示一条: IA64处理器上的未初始化数据带来的问题

警示一条: IA64处理器上的未初始化数据带来的问题

作者:BlogUpdater |  时间:2020-10-20 |  浏览:83 |  评论已关闭 条评论

IA64上容易犯的错误
在周五的时候,我在一篇文章里讲述了错误的函数原型可能会造成的一些奇奇怪怪的问题。在IA64架构处理器上,如果使用了不相匹配的函数原型,则你还会碰到一些可能是比较致命的问题,虽然可能代码看起来没有大的问题。那我们下面具体就来看看。

Win32里有一个CreateThread的API函数,它会接受一个类型为LPTHREAD_START_ROUTINE的函数指针,它需要客户传递形如下面原型的函数:
DWORD CALLBACK ThreadProc(LPVOID lpParameter);

有些朋友喜欢做的事情之一就是:将一个无返回值(void)的函数强制转换为LPTHREAD_START_ROUTINE类型并将函数指针传递给CreateThread。
如果开发者真的这样做了,则他实际在向编译器传递这样的信息:
“我并不关心这个返回值,所以这里我使用了一个无返回值的函数作为线程的函数指针,我知道可能会得到垃圾数据作为返回值,但是那样也没事。”
类似于下面的代码:

实际上,类似这样有缺陷的代码还有很多,大家可以直接搜索关键字”CreateThread LPTHREAD_START_ROUTINE”,就可以找到它们。Microsoft甚至还专门发布了一篇白皮书来讲述这种误用函数原型可能会在Win64上导致一些问题。

看起来很有意思,但出问题的时候,就不那么好玩了。

在IA64架构上,每一个64位寄存器实际上是65位长度的。多出来的一位我们叫做”NaT”,也即”Not A Thing”的缩写。当寄存器里的数据是无效的时候,这个位会被设置为1。它的这种行为,类似于浮点计算中的NaN。

NaT位通常会在CPU的分支预测操作中被设置。在IA64上,有一个特殊的load指令,它用来尝试从内存中加载一个值,如果加载失败(例如, 包含这个值的内存已经页换出(Paged out)或者访问的目标地址无效)时,这个指令不会激发一个页面访问异常,而是将这个NaT位设置为1,然后继续执行后面的指令。

所有对NaT数据的数学计算,都只是重新设置一个NaT标志而已。

上面谈到的这个load指令主要用在指令预测的场景,如下所示:

将上面的代码转换为汇编代码,如下图所示:

我猜,你们中的大多数人很少有机会查看过IA64上的汇编代码吧,由于今天我们不是主要将IA64上的汇编,所以,上面代码中的细节我就不再展开了。

当设置了寄存器栈帧并保存返回地址后,我们尝试加载m_ready的值并执行了一次预加载操作,将*p的值加载到r36寄存器。请注意了,我们在没有确定if条件是否为真的情况下,就执行了if表达式中语句中的代码,这也就是为什么将它称为”分支预测执行”的原因。

(CPU为什么要这样做?因为内存访问操作,在CPU看来是相当慢的。所以,CPU会尽可能多的执行内存访问操作指令,这样CPU就不会在那里闲着。)

接上面的,然后我们开始检查m_ready的值,如果它为非0,则我们执行p15行。第一个指令是”chk.s”,它的意思是:如果r36寄存器是NaT,则从[r33]处执行非预测式load指令,否则,什么有不做。

所以,如果对*p的预加载失败,则chk.s指令会尝试真的尝试加载它,这会导致页面访问错误并触发内存管理器将页面换入(或者,将此异常分发出去,导致内存访问错误(STATUS_ACCESS_VIOLATION))。

一旦r36寄存器的值稳定下来,我们就会调用DoSomething。(因为我们有两个输入寄存器[r32, r33]和两个本地变量寄存器[r34, r35],以及一个输出寄存器r36)

当调用返回时,我们执行栈清理,并将返回值返回给调用者。

请注意,如果m_ready的值是FALSE,则对*p的访问就会失败,接下来,r36寄存器就会被设置为NaT的状态。这里就是上面说的”不好玩”的地方。

就像你看到的,如果一个寄存器的值为NaT,同时你还继续使用它进行某个操作(例如,将它的值保存到内存),则处理器会激发一个STATUS_REG_NAT_CONSUMPTION异常。

(确实还有另外一些指令在处理NaT寄存器时不会激发异常,例如,所有的算术计算操作支持NaT操作数,它们对NaT操作数的操作只会再次触发一次NaT设置。还有另外一个特殊的指令:即使操作数为NaT,也将它保存到内存。这个指令在处理可变参数函数时非常有用)

CreateThread例子中的问题
好了,前面说了这么多的底层原理,让我们回到之前那个CreateThread的例子。

假设,你调用CreateThread将一个void的函数强制转换为LPTHREAD_START_ROUTINE,同时,我们还假设这个函数恰巧由于某种原因,没有正确的完成分支预测执行,进而导致r8被设置为NaT。
这个时候,你会返回到kernel32的线程调度组件中,并将一个NaT作为返回值。Kernel32会尝试将这个NaT值当做线程的退出代码并触发一个STATUS_REG_NAT_CONSUMPTION异常。

你的程序会在内核空间中崩溃,Debug过程会十分难以下手。(祝调试愉快!)

这里还有另外一个类似的问题。当你向一个函数传递的参数比函数声明的原型少的时候,额外缺失的参数可能也会是NaT。幸运的是,即使在函数的实现中因为某些条件不成立而没有访问这个参数,编译器也会侦测到这种情况,并激发一个STATUS_REG_NAT_CONSUMPTION异常。

这种Bug我实际也碰到过,相信我,你绝对不会想要调试这类Bug。(这酸爽,简直不敢相信)

今天(本文发表于2014年),I64架构确实挺热门的,后面我还会再写几篇,讲讲其他在IA64上开发者容易犯的错误。

你得明白,IA64可不像”仁慈的”i386那样好糊弄。

总结
我算是明白了,CreateThread传函数指针的时候,一定要检查传入的函数是否和API预期的原型一致。

最后
Raymond Chen的《The Old New Thing》是我非常喜欢的博客之一,里面有很多关于Windows的小知识,对于广大Windows平台开发者来说,确实十分有帮助。
本文来自:《Uninitialized garbage on ia64 can be deadly》

评论已关闭。