使用静态缓冲区提高错误报告成功率






4.75/5 (3投票s)
使用静态缓冲区可确保错误消息报告成功,即使需要进行令牌替换且内存不足。
引言
当应用程序遇到错误时,您最不希望发生的情况是报告例程失败,或由于用尽字符串空间而无法生成完整的报告,或者因为系统内存不足而无法从堆中获取更多内存。我解决这个问题的方法在过去十年左右的时间里已经悄悄地、私下地演变,最终我对其价值有了足够的信心,并且有一些时间将它们移到一个 DLL 中并撰写一篇关于它的文章。
背景
库的设计考虑了多种因素。
- 根据 Microsoft Windows 平台 SDK 中的“STRINGTABLE 资源”,字符串资源“不得超过 4097 个字符。”因此,任何有效的字符串都可以放入一个包含该数量
TCHAR
的缓冲区中,再加上一个用于终止空字符。因此,用作LoadString
目标缓冲区的分配方式是包含 4098 个unsigned short
元素。 - 由于错误消息通常涉及将少量文本替换到字符串中,因此我仍然使用旧的
sprintf
和swprintf
函数,它们不应产生超过 1024 字节的输出。 (请参阅 MSDN 库中的 wsprintf 函数。)因此,sprtintf
缓冲区分配为包含 1024 个char
元素的数组。 - 根据过去两年的使用经验,我发现很少有程序需要为
LoadString
和sprintf
各使用超过 3 个缓冲区。然而,作为一名保守的人,我构建的 DLL 包含每种类型的 5 个缓冲区。 - 许多应用程序,包括与错误报告相关的应用程序,其中替换令牌比晦涩的
printf
令牌更有用,更不用说替换函数可以操作任意长度的字符串,并且替换字符串只指定一次,而不是像使用 printf 或sprintf
来执行替换那样,每次替换都指定一次。 - 出于许多其他原因(这些原因很容易填满另一篇文章),我刻意避免使用模板、框架和外部库(我自己的库除外),以尽可能消除通过后门插入堆分配的风险,并保持代码“精简高效”,以实现低开销、健壮的错误报告。
- 公共函数使用
__declspec(dllimport)
调用约定,但没有.DEF
文件。虽然我有很多库使用它们,但我发现了一个恼人的副作用,那就是从具有 .DEF 文件的库导入的函数在dumpbin
导入报告中只显示它们的序号。虽然序号对加载程序来说是高效的,但它们对我们这些碳基生物来说效率不高,原因与大多数人有选择时不想直接使用 IP 地址一样。 - 返回字符串的函数具有 ANSI(窄字符)和 Unicode(宽字符)版本,我使用了
TCHAR.H
中定义的通用文本映射,因此两者的源代码几乎相同。为了避免维护两份完全相同的函数体副本,我将它们放入.INL
文件中,这些文件被#include
到提供适当字符编码指令、头文件包含、函数原型和结束大括号的源文件中。 - 此项目不使用预编译头文件,因为当部分(但不是全部)模块定义
UNICODE
和_UNICODE
预处理器符号时,它们会带来比实际价值更多的麻烦。 - 同样,有一个测试程序,为此定义了两个配置,一个定义了
UNICODE
,另一个没有。
软件包清单
此软件包包含大量内容。本节提供有关构成软件包的目录以及其中包含的 DLL 和链接库的清单形式的指南。
下表列出了软件包中的目录并进行了描述。
目录名称 | 摘要 |
---|---|
FixedStringBuffersTestStand |
库的执行器和演示程序 |
INCLUDE |
库的头文件,包括文章主题库 |
LIB |
构建项目所需的链接库 |
注释 |
注释和参考文档 |
QueryRoutineNames |
字符串资源的卫星 DLL |
Release |
DLL 二进制文件和列表 |
scripts |
构建后步骤使用的脚本,以及 |
FixedStringBuffersTestStand\FixedStringBuffersTestStand___Win32_Unicode_Release |
测试站程序的发布版本,配置为使用所有 Unicode 字符串,因此用于测试 Unicode 例程 |
FixedStringBuffersTestStand\Release |
测试站程序的发布版本,配置为使用所有 ANSI 字符串,因此用于测试 Unicode 例程 |
QueryRoutineNames\Release |
字符串资源卫星 DLL 的发布版本 |
QueryRoutineNames\scripts |
构建后步骤中调用以更新测试站程序目录的脚本 |
下表描述了动态链接库,其中一些是使用该库所必需的,所有这些都由演示程序使用。
库名称 | 摘要 |
---|---|
P6CStringLib1.dll |
此库中的字符串操作例程执行了我希望框架能够很好地或至少能够执行的操作。虽然我最终在 MFC 中找到了等效的函数,但这使得它们对于仅支持 __stdcall 的程序(最多也就是如此)无法使用,包括 VBA 甚至像 WinBatch 这样健壮的脚本语言。 |
P6VersionInfo.dll |
此库导出一个少量方便的例程,用于从版本资源构建徽标横幅。这是我最早的 Windows API 包装例程库之一,所有这些例程都用纯 C 实现,并通过 __stdcall 导出。 |
ProcessInfo.dll |
这个相当新的库提供了对当前进程及其加载的模块信息的便捷访问,例如,模块加载的文件的完全限定名称、指定模块加载的目录名称以及当前进程所有者的用户的完全限定 NetBIOS 名称。 |
SafeMemCpy.dll |
此库导出一个函数(实际上是两个,因为它有 ANSI 和 Unicode 实现),该函数使用 memcpy 来提供高效、安全的字符串追加和复制。安全性指的是例程在必要时使用 HeapReAlloc 来扩展目标缓冲区,以确保它能够容纳所请求的复制或追加操作。 |
WWDateLib.dll |
此库与 P6VersionInfo.dll 的年龄差不多,它导出的例程使用替换令牌将 SYSTEMTIME 结构成员格式化为人类可读的日期。P6VersionInfo.dll 中定义的横幅例程使用它们来显示当前日期。 |
WWKernelLibWrapper.dll |
此库中最重要的例程是三个将 HeapAlloc、HeapReAlloc 和 HeapFree 封装在结构化异常处理块中的例程,以便在遇到问题时返回系统状态码。HeapFree 也受到 HeapSize 调用之前调用的保护,其返回值用于检测指定的指针是否来自指定的堆。 |
除了少量 Windows NT 命令脚本以及它们依赖的可执行程序之外,脚本目录还包含几个 Microsoft .NET 程序集,Date2FN.exe 依赖于它们。由于 .NET 程序集的加载方式,它们必须与 Date2FN.exe 保留在一起。
使用代码
两个软件包的 INCLUDE
子目录都包含一个标准的 C/C++ 头文件 FixedStringBuffers.H
,该文件声明了所需的常量和导出的例程。库 include 文件将两个额外的头文件(均位于同一目录中)包含到编译中。在仔细权衡风险后,我决定交付软件包的最安全方法是识别头文件依赖项,将它们全部保留,并建议您将 INCLUDE
目录中的所有内容安装到您自己选择的目录中,只要它满足一个要求:它必须属于您的 INCLUDE
环境变量中命名的目录列表。我的开发机上就是这样安装的,这样预处理器就可以找到它们,因为这些目录是搜索尖括号中名称的 include 文件的地方。其他头文件是构建库和演示程序所必需的。
名称 | 摘要 |
---|---|
Const_Typedefs_WW.H |
定义了我在 Platform SDK 头文件中找不到的 const typedefs,但它们用于保护参数免受意外更改的影响,以免在更改被反映回调用应用程序时对其产生不利影响。 |
WWStandardErrorMessages.H |
定义了字符串资源 ID 和相关的应用程序状态码,用于大多数应用程序中频繁出现的条件。资源字符串位于 WWStandardErrorMessages.dll 中,主 DLL 期望在其加载的目录中找到它。因此,我在主 DLL 的 Debug 和 Release 目录中放置了一个副本。 |
两个软件包的 FixedStringBuffersTestStand
都包含一个同名的程序,其主例程定义在 FixedStringBuffersTestStand.C
中,而其他例程定义在 FB_LoadStringFromNamedDLLA.CPP
中。这两个源文件共同演示了该库的全部功能。
导航辅助
为了帮助您导航库,下表总结了主要的辅助例程,所有这些例程都具有 ANSI 和 Unicode(宽字符)实现。
名称 | 返回 | 摘要 | 缓冲区 |
|
|
使用此例程通过消息框(适用于任何程序)或控制台(适用于字符模式程序)报告错误,并返回指定的状态码,除非进一步的错误(例如,缺少资源字符串)阻止了原始错误的报告。 |
|
|
|
使用此例程直接格式化系统状态码的消息。返回值是指向字符串的指针,可供您随意使用。 |
|
|
|
使用此例程从您拥有有效 |
0-4 |
|
|
使用此例程从您拥有文件名(File Name)的模块加载字符串。指定的模块被映射到调用进程的地址空间,请求的字符串被读取到缓冲区中,然后模块被卸载。 |
0-4 |
|
|
使用此例程格式化最多 4097 个字符(资源字符串的最大支持长度)的字符串。输入字符串、要查找的文本和替换文本可以来自任何地方,但新字符串始终来自 DLL 的单个专用缓冲区。 |
不适用 |
除了主要的辅助例程外,还有一些服务例程可以从 DLL 返回有用的信息,包括它支持的每种类型的缓冲区的数量、各种类型缓冲区的尺寸以及它们的机器地址。下表总结了这些例程。
名称 | 返回 | 摘要 | 缓冲区 |
|
|
获取资源字符串加载到的缓冲区的地址,该字符串作为输入传递给 FB_ReportErrorViaStaticBuffer。 |
紧急消息资源字符串缓冲区 |
|
|
获取 FB_ |
紧急消息 sprint 输出缓冲区 |
|
|
获取指定用作 |
0-4 |
|
|
在极少数情况下,当辅助例程返回 |
紧急消息 sprint 输出缓冲区 |
|
|
获取 |
不适用 |
|
|
获取资源字符串缓冲区的数量。您在任何对 |
不适用 |
|
|
获取每个 |
不适用 |
|
|
获取每个资源字符串缓冲区的 |
不适用 |
从缓冲区复制字符串
FB_LoadString
和 FB_LoadStringFromDLL
的第四个参数是 plpuintLength
,它是一个指向无符号整数位置的指针,如果它不为 NULL
,则接收底层 LoadString
系统例程返回的字符计数。
- 如果您打算就地使用字符串,可以通过传递
NULL
来节省程序中的 4 字节存储空间和 DLL 中的一些机器周期。但是,该参数必须始终进行空值测试,并且通过提供的指针返回值只需要两个机器指令。 - 出于同样的原因,
FB_Replace
有一个名为puintNewLength
的第四个参数,以强调它报告的是新字符串的长度。
从固定缓冲区将字符串复制到您自己的缓冲区中的最快方法是调用 memcpy
或 CopyMemory
(后者在底层调用 memcpy
),将您的缓冲区地址作为第一个参数,将 FB_LoadString
、FB_LoadStringFromDLL
或 FB_Replace
返回的地址作为第二个参数,将字符计数乘以 sizeof ( TCHAR )
作为第三个参数。如果未能按我上述描述的方式乘以字符计数,那么在缓冲区由 Unicode 字符组成时,将只能复制出一半内容。
下面摘录自 FB_ReportErrorViaStaticBuffer
(这个例程促使我将这些例程收集到一个库中)的代码片段,演示了使用 memcpy
来复制 FB_Replace
生成的新字符串,而 FB_Replace
在循环中被调用了多次,以替换错误消息模板中嵌入的令牌。
for ( intSrchIndx = ARRAY_FIRST_ELEMENT_P6C ; intSrchIndx < sizeof ( m_aszTokens ) / sizeof ( m_aszTokens [ ARRAY_FIRST_ELEMENT_P6C ] ) ; intSrchIndx++ )
{
lpChanged = FB_Replace ( lpErrMsgResStr ,
m_aszTokens [ intSrchIndx ] ,
alpReplacements [ intSrchIndx ] ,
&uintNewLen ) ;
if ( StringIsNullOrEmptyWW ( lpChanged ) )
{
_stprintf ( m_lpFBReplaceBuff ,
FB_XlateFBStatusCode ( GetLastError ( ) ) ) ;
lpFinalMessage = m_lpFBReplaceBuff ;
} // TRUE (UNexpected outcome) block, if ( StringIsNullOrEmptyWW ( lpChanged ) )
else
{
if ( IsLastLoopLT_WW ( intSrchIndx , ( sizeof ( m_aszTokens ) / sizeof ( m_aszTokens [ ARRAY_FIRST_ELEMENT_P6C ] ) ) ) )
{
lpFinalMessage = lpChanged ;
} // TRUE (Final iteration) block, if ( IsLastLoopLT_WW ( intSrchIndx , ( sizeof ( m_aszTokens ) / sizeof ( m_aszTokens [ ARRAY_FIRST_ELEMENT_P6C ] ) ) )
else
{
memcpy ( lpErrMsgResStr ,
lpChanged ,
TcharsMinBufSizeP6C ( uintNewLen ) ) ;
} // FALSE (More iterations to go) block, if ( IsLastLoopLT_WW ( intSrchIndx , ( sizeof ( m_aszTokens ) / sizeof ( m_aszTokens [ ARRAY_FIRST_ELEMENT_P6C ] ) ) )
} // FALSE (expected outcome) block, if ( StringIsNullOrEmptyWW ( lpChanged ) )
} // for ( intSrchIndx = ARRAY_FIRST_ELEMENT_P6C ; intSrchIndx < sizeof ( m_aszTokens ) / sizeof ( m_aszTokens [ ARRAY_FIRST_ELEMENT_P6C ] ) ; intSrchIndx++ )
既然我通过显示代码片段“打破了僵局”,我将换个思路,引起您注意上面示例以及后续示例中一些重要但不显而易见的地方。
关注点
上面所示的循环展示了很多您将在我的代码中看到的方面。
- for 语句的初始化子句使用
ARRAY_FIRST_ELEMENT_P6C
,这是一个展开为零的数值的宏。我使用此类宏来记录幻数,这是我对数组下界的看法。 - 虽然限制子句是源代码中的一个表达式,但即使是代码的调试版本也会将该表达式转换为即时(硬编码)常量,这在上面所示的
for
语句的反汇编片段中可以看到。之所以能够实现这一点,是因为所需的值在编译时全部已知,因此编译器计算值并将其嵌入到代码中。
81: for ( intSrchIndx = ARRAY_FIRST_ELEMENT_P6C ; intSrchIndx < sizeof ( m_aszTokens ) / sizeof ( m_aszTokens [ ARRAY_FIRST_ELEMENT_P6C ] ) ; intSrchIndx++ )
10002DBF mov dword ptr [ebp-24h],0
10002DC6 jmp FB_ReportErrorViaStaticBufferW+1D1h (10002dd1)
10002DC8 mov ecx,dword ptr [ebp-24h]
10002DCB add ecx,1
10002DCE mov dword ptr [ebp-24h],ecx
10002DD1 cmp dword ptr [ebp-24h],3 ;sizeof ( m_aszTokens ) / sizeof ( m_aszTokens [ ARRAY_FIRST_ELEMENT_P6C ] )
10002DD5 jae FB_ReportErrorViaStaticBufferW+286h (10002e86)
限制测试求值是机器地址 10002DD1 处的 cmp
指令;MASM 风格的注释是从我的工作笔记中逐字复制的,我从那里复制了上面的片段。
- 出于同样的原因,我没有在可执行文件中浪费空间来求值和存储表达式,以便在最后的迭代测试中使用,该测试以“
if ( IsLastLoopLT_WW
”开头,该测试抑制了最后一次迭代中的内存复制,因为最终字符串可以在其所在位置使用。 - 在新长度的每一轮迭代中,都会捕获到
uintNewLen
中,它在例程的顶部分配,并传递给memcpy
以在迭代之间复制字符串,以便重用输出缓冲区。(在我写下这些的时候,我意识到可以通过分配第二个缓冲区并在每次迭代之间交替使用它们来消除复制。我将这个作为留给有抱负的读者或库的下一个版本的练习。) - 尽管它看起来像一个函数调用,但
TcharsMinBufSizeP6C
是一个参数化宏,它隐藏了我上面描述的乘以sizeof ( TCHAR )
的乘法,并考虑了尾随的 null。- 每次都复制尾随的 null 可以安全地重用缓冲区而无需初始化它们。
- 宽字符计算仅需要一个机器指令,因为
mov
和push
不计入,因为两者都必须将数字放入参数列表中。
10002E66 mov edx,dword ptr [ebp-20h]
10002E69 lea eax,[edx+edx+2]
10002E6D push eax
机器地址 10002E69
处的中间指令考虑了宽字符的 sizeof ( TCHAR )
和尾随的 null(+2
)。当 UNICODE
未定义时,该指令变为 add edx, 1
,并且 edx
被推送到堆栈上。
- 最后一次迭代测试是另一个参数化宏;此宏生成一个表达式,该表达式仅在循环的最后一次迭代时求值为 true。
#define IsLastLoopLT_WW(pintLoopIndex,pintLoopLimit) ( ( pintLoopIndex + 1 ) == pintLoopLimit )
由于此循环的限制测试是索引小于上限,因此当索引比循环少一时,循环停止。为什么这个表达式有效,以及反汇编,留给有兴趣的读者作为练习。
- 此块中的最后一个函数样式宏
StringIsNullOrEmptyWW
受 Microsoft .NET Framework 中静态string.IsNullOrEmpty
方法的启发,并且其行为与该方法完全相同。该宏很简单,生成的机器代码也很简单。
#define StringIsNullOrEmptyWW(plpString) ( ( BOOL ) ( plpString == NULL || StringIsEmptyWW ( plpString ) ) )
上面代码片段中用于实现宏的机器代码如下所示。
85: if ( StringIsNullOrEmptyWW ( lpChanged ) )
003B37EE cmp dword ptr [ebp-8],0
003B37F2 je FB_ReportErrorViaStaticBufferA+20Eh (003b37fe)
003B37F4 mov edx,dword ptr [ebp-8] ; EDX = 003D8E10 = lpChanged 0x003d8e10 "Reporting via static buffer.\nAdditional Info: This is a test hint.\n\n"
003B37F7 movsx eax,byte ptr [edx] ; EAX = 00000052
003B37FA test eax,eax
003B37FC jne FB_ReportErrorViaStaticBufferA+24Bh (003b383b)
上面摘录自我的工作笔记的示例,显示了对一个既非 null 也非空的字符串的测试的寄存器值。
同时使用两个或多个缓冲区
我认为值得关注的最后一点是演示一个需要访问多个(准确地说,三个)静态缓冲区的案例。该示例是 FixedStringBuffersTestStand.C
中定义的 StagingOrbits
例程,下面是其大部分内容的重现。
#define FB_BUFFER_INDEX_TOSEARCH ( FB_GUARANTEED_BUFFER + ARRAY_NEXT_ELEMENT_P6C )
#define FB_BUFFER_INDEX_TOFIND ( FB_BUFFER_INDEX_TOSEARCH + ARRAY_NEXT_ELEMENT_P6C )
#define FB_BUFFER_INDEX_REPLACEMENT ( FB_BUFFER_INDEX_TOFIND + ARRAY_NEXT_ELEMENT_P6C )
for ( uintStrData = STRDATA_FIRST ;
uintStrData <= STRDATA_LAST ;
uintStrData++ )
{
lpStrData = FB_LoadTestString ( uintStrData ,
FB_BUFFER_INDEX_TOSEARCH ) ;
for ( uintStrFind = TOFIND_FIRST ;
uintStrFind <= TOFIND_LAST ;
uintStrFind ++ )
{
lpStrFind = FB_LoadTestString ( uintStrFind ,
FB_BUFFER_INDEX_TOFIND ) ;
for ( uintStrRepl = TOREPLACE_FIRST ;
uintStrRepl <= TOREPLACE_LAST ;
uintStrRepl++ )
{
* plpOrbit += 1 ;
lpStrRepl = FB_LoadTestString ( uintStrRepl ,
FB_BUFFER_INDEX_REPLACEMENT ) ;
lpReplaced = FB_Replace ( lpStrData ,
lpStrFind ,
lpStrRepl ,
&uintLength ) ,
lpReplaced4lOG = StrReplace_P6C ( ( lpReplaced
? lpReplaced
: FB_XlateFBStatusCode ( GetLastError ( ) ) ) ,
_T ( "\n" ) ,
_T ( "[NEWLINE]" ) ) ;
lpstrData4Log = StrReplace_P6C ( lpStrData , _T ( "\n" ) , _T ( "[NEWLINE]" ) ) ;
lplpStrFind4Log = StrReplace_P6C ( lpStrFind , _T ( "\n" ) , _T ( "[NEWLINE]" ) ) ;
lpStrRepl4Log = StrReplace_P6C ( lpStrRepl , _T ( "\n" ) , _T ( "[NEWLINE]" ) ) ;
_tprintf ( lpMsgTpl ,
* plpOrbit ,
uintStrData ,
lpstrData4Log ,
uintStrFind ,
lplpStrFind4Log ,
uintStrRepl ,
lpStrRepl4Log ,
lpReplaced4lOG ,
uintLength ) ;
FreeBuffer_WW ( lpstrData4Log ) ;
FreeBuffer_WW ( lplpStrFind4Log ) ;
FreeBuffer_WW ( lpStrRepl4Log ) ;
FreeBuffer_WW ( lpReplaced4lOG ) ;
} // for ( uintStrRepl = TOREPLACE_FIRST ; uintStrRepl <= TOREPLACE_LAST ; uintStrRepl++ )
} // for ( uintStrFind = TOFIND_FIRST ; uintStrFind <= TOFIND_LAST ; uintStrFind ++ )
} // for ( uintStrData = STRDATA_FIRST ; uintStrData <= STRDATA_LAST ; uintStrData++ )
此例程的目标是彻底测试 FB_Replace
库例程,该例程接受三个字符串参数(全部为输入)和一个第四个参数,即一个指向 UINT
变量的指针,该变量接收新字符串的长度。
测试例程 StagingOrbits
实现为一个嵌套的 for
循环,每个输入对应一个循环。由于在最内层循环调用 FB_Replace
例程时必须同时存在这三个输入,因此它使用了五个资源字符串缓冲区中的三个,这些缓冲区由列表顶部的三个符号常量定义。
由于它们并非测试的真正部分,而是用于格式化输出以便 Microsoft Excel 分析,因此字符串 lpstrData4Log
、lplpStrFind4Log
和 lpStrRepl4Log
是在动态分配的缓冲区中构建的,使用了 StrReplace_P6C
,即 FB_Replace
的前身,它会按需从堆分配内存,因此可以处理任意长度的字符串。与它的后继者不同,StrReplace_P6C
没有提供返回其完成字符串长度的机制,尽管可以通过将 HeapSize
的返回值除以 sizeof ( TCHAR )
再减去商值 1 来推导。为什么这比将字符串传递给 _tcslen
快得多,留给您这些思维体操爱好者作为练习。我提供的唯一额外提示是,此方法有效,因为返回的缓冲区正好足够容纳返回的字符串。
StrReplace_P6C
和 FB_Replace
使用的算法之间存在许多差异。我只想说,我认为在FB_Replace
中实现的算法在几个方面更为健壮,并且很有可能被改编为使用动态内存,成为 StrReplace_P6C
的改进版本。.
学到的经验或再次确认的知识
- 再次确认:以字符为单位计算字符串中的偏移量,让编译器将其转换为字节。因为您无法在不付出过多努力的情况下阻止它,所以最好还是这样做。
- 再次确认:先测试 Unicode 版本,ANSI 版本很可能也会自行处理好。
- 学到:CRT 字符串例程在接收到 null 引用时会严重失败。我解决此问题的方法是
TcsLenEvenIfNull
,这是一个函数式宏,它将我的StringIsNullOrEmptyWW
宏(上面讨论过)包装在一个三元表达式中,该表达式仅在字符串指针非 null 且字符串长度大于零时才调用_tcslen
。这可以在真正需要时节省函数调用,并避免了糟糕的 null 引用异常。TcsLenEvenIfNull
定义在FixedStringBuffers_Pvt.H
中,这是主 DLL 项目的一部分;其详细分析留作实验练习。 - 学到:避免字符串 ID 号冲突的最简单也是最好的方法是将字符串分组到卫星 DLL 中。这个教训最终促成了库函数
FB_LoadStringFromDLL
的创建,以及使FB_Replace_Test_Strings.XLSM
魔法生效的 VBA 宏。 冲突防护共享字符串资源 就是关于 Excel 工作簿及其魔法的,包括工作簿的改进版本,以及用于创建自己的字符串 DLL 的 Visual Studio 模板项目。同时,我在项目的NOTES
目录中留下了一个副本供您探索。 [新版本] 截至 2015 年 6 月 2 日,下载包包含了我与文章一起发布的改进工作簿的副本,以及其中嵌入的 VBA 源代码导出。新版本除了一些 bug 修复外,还增加了一个启动宏的热键,Ctrl-Shift-G。宏项目已锁定但未签名。(为防止意外更改,我锁定我的 VBA 项目),并且工作表中的关键公式免受意外更改的保护,以及用于生成资源脚本及其头文件的查找工作表。如果您上个月下载了本文的存档,您可能想再次下载以获取更新的NOTES
目录。更好的是,使用上面的超链接在新浏览器窗口中打开另一篇文章,阅读它,并获取其演示包。
历史
2015 年 4 月 8 日 - 文章发布。
2015 年 6 月 2 日 - 向两个下载包添加了 FB_Replace_Test_Strings.XLSM 的新版本,修改文章以涵盖新软件包并包含指向关于工作簿的文章和相关 C/C++ 代码的链接,改写了一些句子,并进行了一些美化更改。