本不该有代码生成影响却发生整数神秘变化的案例The case of the mysterious changes to integers when there shouldn’t have been any code generation effect
微软《The Old New Thing》博客探讨了一个调试案例:在理论上不应产生代码生成影响的情况下,整数却发生了神秘变化。文章深入解码了这些异常整数的来源,揭示了底层编译器或代码生成过程中的隐藏机制。
Raymond Chen
一位同事做了一些代码更改,这些更改本不应该对生成的二进制文件产生任何影响。具体来说,他们将 NDIS_STRING_CONST 宏迁移到了类型更安全的 RTL_CONSTANT_STRING 宏。归根结底,这两个宏产生的结果是相同的,因此预期这不会导致二进制文件发生任何变化。
但他们发现二进制文件发生了变化。
具体来说,有四个函数发生了变化,而特别奇怪的是,它们都不涉及宏的更改。其中三个函数在一个源文件中,而第四个函数在一个甚至根本没被碰过的源文件中!
更改看起来是这样的:
在所有情况下,变化都是单个整数变成了一个比原来小一的值。
我的同事让 LLM 解释这个变化,它建议说这些变化与控制流防护元数据有关。这说得通吗?
对我来说这说不通,有两点原因。首先,对于防护调度调用,控制流防护的唯一参数是 rax 寄存器,即正在被检查的函数。所有其他寄存器都包含被调用函数的参数。既然变化发生在 r9d 寄存器上,它们就与控制流防护无关。
其次,控制流防护元数据不存储在代码中。它作为数据块存储在二进制文件内部。
那么我们看到了什么?
我查看了一下 EvtWdfWidgetContextCleanup。
void EvtWdfWidgetContextCleanup(_In_ WDFOBJECT Object)
{
auto widgetContext = GetContextFromWidgetHandle(Object);
if (widgetContext->NeedsDereference)
{
widgetContext->NeedsDereference = FALSE;
WdfObjectDereferenceWithTag(Object, CONTOSO_WIDGET_TAG);
}
}编译器指出 WdfObjectDereferenceWithTag 是发生更改的位置。而且我们看到它被定义为一个宏:
#define WdfObjectDereferenceWithTag(Handle, Tag) \
WdfObjectDereferenceActual(Handle, Tag, __LINE__, __FILE__)它本身是一个内联函数:
_IRQL_requires_max_(DISPATCH_LEVEL)
VOID
FORCEINLINE
WdfObjectReferenceActual(
_In_
WDFOBJECT Handle,
_In_opt_
PVOID Tag,
_In_
LONG Line,
_In_z_
PCCH File
)
{
((PFN_WDFOBJECTREFERENCEACTUAL) WdfFunctions[WdfObjectReferenceActualTableIndex])
(WdfDriverGlobals, Handle, Tag, Line, File);
}最后一个小细节是,WdfFunctions 是一个展开为 WdfFunctions_01031 的宏。WDF 头文件为每个版本赋予了一个唯一的名称,这样版本不匹配会导致链接器错误,而不是运行时的未定义行为。
现在我们可以看到这段代码是如何映射到编译器输出的。
mov rax, [contoso!WdfFunctions_01031] ; WdfFunctions
lea rcx, [??_C@__0DK@MPBCIIPN@...] ; Address of something
mov [rsp+20h], rcx ; is the File parameter
mov r9d, 62Bh ; Line parameter
mov r8d, 52467443h ; Tag parameter
mov rcx, [contoso!WdfDriverGlobals] ; hard-coded parameter
mov rdx, rbx ; Handle parameter
mov rax, [rax+670h] ; Load the function pointer
call __guard_dispatch_call ; Validate and call¹所以发生变化的值是行号。
我回去查看了那个拉取请求,发现该拉取请求从源文件中删除了一行。
#include <strsafe.h>
#include "stringutils.h"拉取请求的一部分包括删除不再需要的头文件,因为它包含了 NDIS_STRING_CONST 宏的私有定义,而代码不再使用这个宏了。
从源文件中删除一行会导致所有行号都偏移一位!
所以他们看到的只是行号的变化。功能上没有任何改变。
如果他们真的想让这成为一次“对二进制文件无影响”的更改,他们可以用注释替换 #include "stringutils.h,或者干脆留一个空行。
或者他们也可以直接接受行号会随着代码行的更改而改变这一事实。
额外闲聊:但是等等,我说过有三个更改在一个文件中,也就是删除了那一行的那个文件,但第四个更改在一个根本没有改变的文件中。这是怎么回事?
第四个函数包含了对修改文件中某个函数的调用,而链接时代码生成决定将该调用内联。更改后的行号传播到了内联函数中,并导致在一个甚至根本不受该拉取请求影响的文件中发生了代码生成的变化。
¹ 回想一下,在验证并调用模式中,函数指针是通过 rax 寄存器传递的,而其他所有内容的设置方式就像是你自己在调用该函数一样。
作者
Raymond 参与了 Windows 的演进已有 30 多年。2003 年,他创办了一个名为 The Old New Thing 的网站,其受欢迎程度远超他的想象,这一发展至今仍让他感到有些忐忑不安。这个网站催生了一本书,巧合的是,书名也叫《The Old New Thing》(Addison Wesley 2007 年出版)。他偶尔会出现在 Windows Dev Docs 的 Twitter 账号上,讲一些毫无实用信息的故事。
需要完整排版与评论请前往来源站点阅读。