为何不使 API 行为变更依赖于链接的 SDK?Why not have changes in API behavior depend on the SDK you link against?
文章探讨微软是否应让 API 的行为随链接的 SDK 版本而变化,以增强兼容性控制。作者认为静态库在这种机制下处于劣势,难以适配不同 SDK 的变更。最终结论是,动态链接或模块化设计更适合实现此类灵活性,而静态库架构限制了版本隔离能力。
Raymond Chen
不久前,我注意到 CoInitializeSecurity 函数要求一个绝对的安全描述符,尽管 Windows 中的许多函数都会生成自相对安全描述符,迫使你执行从相对到绝对的转换,即使该函数内部只是将其从绝对转换回相对。
评论者 tbodt 写道:
这似乎很容易通过苹果的技巧来解决——当程序链接到旧 SDK 时,让函数恢复旧行为。
听起来确实很简单。如果你的程序链接的是新 SDK,那么它就会获得接受自相对安全描述符的新行为;而如果链接的是旧 SDK,则仍需要绝对安全描述符的旧行为。如果你想使用新行为,那就链接新 SDK。
但这会带来一个微妙的问题:如果你选错了要链接的 SDK,程序仍然可以构建,但结果却不同。传统上,Windows SDK 是向前兼容的:你可以用旧程序链接一个新 SDK,它仍能完全正常工作,因为旧程序只使用了新 SDK 中向后兼容的子集。但如果根据所链接的 SDK 版本改变行为,你可能不会意识到当前的行为变化正是由于升级了 SDK 库所致。
此外,如果一个程序链接的是某个版本的 SDK,但它使用的一个 DLL 链接的是另一个版本的 SDK,又该如何处理?也许你正在使用一个 UI 框架库,它觉得没必要更新到新 SDK。或者反过来,你的主程序使用的是旧版 SDK,而 UI 框架库用的是新版。这时,你是让主程序的 SDK 版本决定函数行为,即使 DLL 期望的是不同的行为?这样一来,那个可怜的 DLL 调用 CoInitializeSecurity 时,其行为将不符合预期。
好吧,也许你会决定:函数的行为不应基于主程序链接的 SDK 版本,而应基于调用它的 DLL 的版本。但问题是,函数如何知道是哪一个 DLL 调用了它?有人可能会说:“哦,你可以查看返回地址属于哪个 DLL。”但这在尾调用优化(tail call optimization)的情况下不起作用。
// some function in a DLL
HRESULT InitializeWidgets(
UINT maxWidgets,
const WIDGET_ID* ownerId,
PCWSTR ownerDescription,
PCWSTR countainerName,
PCWSTR containerDescription,
COLORREF defaultColor,
UINT defaultWidth,
UINT defaultHeight,
bool isRemoteAccessible,
bool isPersistent)
{
⟦ various initialization steps ⟧
static BYTE sd[] = { 0x01, ⟦ hard-coded values ⟧ };
return CoInitializeSecurity(sd, -1, nullptr, nullptr,
RPC_C_AUTHN_LEVEL_DEFAULT,
RPC_C_IMP_LEVEL_IDENTIFY,
nullptr, EOAC_NONE, nullptr);
}对 CoInitializeSecurity 的最后一次调用可能被优化为尾调用——此时子例程调用指令会变成无条件跳转,返回地址变为 InitializeWidget 调用者的地址。如果 CoInitializeSecurity 检查自己的返回地址,它查到的将是错误 DLL 的 SDK 版本。
相反,如果 DLL 中的函数只是一个包装器(wrapper)呢?
HRESULT CoInitializeSecuritywithLogging(
_In_opt_ PSECURITY_DESCRIPTOR pSecDesc,
_In_ LONG cAuthSvc,
_In_reads_opt_(cAuthSvc) SOLE_AUTHENTICATION_SERVICE* asAuthSvc,
_In_opt_ void* pReserved1,
_In_ DWORD dwAuthnLevel,
_In_ DWORD dwImpLevel,
_In_opt_ void* pAuthList,
_In_ DWORD dwCapabilities,
_In_opt_ void* pReserved3)
{
if (dwCapabilities & EOAC_APPID) {
LogUuid("CoInitializeSecurity with APPID", (UUID*)pSecDesc);
} else if (dwCapabilities & EOAC_ACCESS_CONTROL) {
Log("CoInitializeSecurity with IAccessControl");
} else {
LogSecurityDescriptor("CoInitializeSecurity with security descriptor", pSecDesc);
}
HRESULT hr = CoInitializeSecurity(pSecDesc, cAuthSvc, asAuthSvc, pReserved1,
dwAuthnLevel, dwImpLevel, pAuthList, dwCapabilities, pReserved3);
Log("CoInitializeSecurity returned", hr);
}如果你查看返回地址,会发现是包装器函数,于是你根据其构建时的 SDK 版本调整行为;但这个包装器只是将从调用者那里接收到的参数原样传递出去。我们真正想匹配的其实是调用者本身的行为,而不是包装器。
那如果这个库是静态库而非 DLL 呢?它原本是为某个 SDK 版本编写的,但你链接到了另一个版本,导致行为发生变化。即便函数检查返回地址,它获取的也是 DLL 的地址及其对应的 SDK 版本,而不是该库原本期望的版本。
只有当程序是单体结构时,才可能根据所链接的 SDK 版本来改变行为。
补充说明:改用新版 SDK 的头文件确实会导致行为变化,例如某些带有显式大小成员的结构体可能会被扩展以包含额外字段,而 API 会利用该大小成员的数值来判断调用方使用的是哪个版本的 SDK。但这并不取决于调用方链接的是哪个 SDK,这是个优点,因为它允许你将使用不同版本 SDK 头文件的静态库全部链接到同一个程序或 DLL 中,它们仍能正常工作。
作者
Raymond 参与 Windows 系统演进已有三十余年。2003 年,他创建了一个名为《The Old New Thing》的网站,其受欢迎程度远远超出了他的想象,这一发展至今仍让他感到些许不安。该网站催生了一本同名书籍——《The Old New Thing》(Addison Wesley,2007)。他偶尔也会出现在 Windows Dev Docs 的 Twitter 账号上,讲述一些毫无实用价值的故事。
需要完整排版与评论请前往来源站点阅读。