65.9K
CodeProject 正在变化。 阅读更多。
Home

应对发布版本

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (91投票s)

2000年5月17日

viewsIcon

540298

了解调试版本和发布版本之间的区别和问题。

引言

好的,你的程序工作正常。你已经测试了所有能测试的地方。是时候发布它了。于是你创建了一个发布版本。

然后世界崩塌了。

你遇到了内存访问失败,对话框没有出现,控件不工作,结果不正确,或者以上所有问题,再加上一些你应用程序特有的问题。

现在怎么办?

这正是本文的全部内容。

背景知识

一些背景:我从1969年就开始使用优化编译器。我的博士论文(1975年)是关于优化编译器中复杂优化的自动生成。我的博士后工作涉及在一个多处理器上构建一个大型(50万行源代码)操作系统时使用高度优化的编译器(Bliss-11)。之后,我是CMU的PQCC(生产质量编译器-编译器)项目架构师之一,该项目是一个旨在简化复杂优化编译器创建的研究项目。1981年,我离开大学,加入了Tartan Laboratories公司,一家开发高度优化编译器的公司,我是编译器工具开发的主要参与者之一。我与优化编译器共处、合作、构建、调试并生存了30多年。

编译器错误

通常的第一个反应是“优化器有bug”。虽然这确实可能,但它实际上是万不得已的原因。更有可能是你的程序出了其他问题。我们稍后会回到“编译器bug”的问题。但第一个假设是编译器是正确的,你有不同的问题。所以我们先讨论这些问题。

存储分配器问题

MFC运行时的调试版本与发布版本分配存储的方式不同。特别是,调试版本在每个存储块的开头和结尾分配了一些空间,因此其分配模式有些不同。存储分配的变化可能导致在调试版本中不会出现的问题出现——但几乎总是这些是真正的问题,就像你程序中的bug一样,它们不知何故在调试版本中没有被检测到。这些通常很少见。

为什么它们很少见?因为MFC分配器的调试版本会将所有存储初始化为真正错误的数值,因此尝试使用你未能分配的存储块将导致在调试版本中立即出现访问故障。此外,当存储块被释放时,它会被初始化为另一种模式,因此如果你保留了任何指向该存储的指针并在释放后尝试使用该块,你也会立即看到一些错误的行为。

调试分配器还会检查其分配块的开头和结尾处的存储,以查看它是否受到任何损坏。典型的问题是你将一个n个值的块分配为数组,然后访问元素0到n,而不是0到n-1,从而覆盖数组末尾的区域。这种情况大多数时候会导致断言失败。但并非总是如此。这导致了潜在的失败。

存储以量子块(quantized chunks)的形式分配,量子的具体大小未指定,但可能像16或32字节。因此,如果你分配一个包含六个元素的`DWORD`数组(大小 = `6 * sizeof(DWORD)`字节 = 24字节),那么分配器实际上会提供32字节(一个32字节量子或两个16字节量子)。所以如果你写入元素[6](第七个元素),你会覆盖一些“死区”,并且错误不会被检测到。但在发布版本中,量子可能是8字节,会分配三个8字节的量子,写入数组的[6]元素将覆盖属于下一个块的存储分配器数据结构的一部分。之后就会一路下坡。这个错误甚至可能直到程序退出时才显现出来!你可以为任何大小的量子构造类似的“边界条件”情况。因为两种版本的分配器的量子大小相同,但调试版本的分配器会为自身目的添加隐藏空间,所以你在调试模式和发布模式下会得到不同的存储分配模式。

未初始化的局部变量

也许导致发布版本与调试版本失败的最大单一原因是未初始化的局部变量的出现。考虑一个简单的例子

thing * search(thing * something)
BOOL found;
for(int i = 0; i < whatever.GetSize(); i++)
{
	if(whatever[i]->field == something->field)
	{ /* found it */
		found = TRUE;
		break;
	} /* found it */
}
if(found)
	return whatever[i];
else
	return NULL;
}

看起来非常直接,除了未能将 *found* 变量初始化为 `FALSE`。但这个 bug 从未在调试版本中出现!然而在发布版本中发生的是,持有 *n* 个元素的 *whatever* 数组会返回 `whatever[n]`,这是一个明显无效的值,这随后导致程序的其他部分严重失败。为什么这在调试版本中没有出现?因为在调试版本中,完全由于一个偶然的巧合,*found* 的值总是初始为0(`FALSE`),所以当循环在没有找到任何东西的情况下退出时,它正确地报告没有找到任何东西,并返回 `NULL`。

为什么堆栈不同?在调试版本中,帧指针总是在例程入口时被压入堆栈,并且变量几乎总是被分配在堆栈上的位置。但在发布版本中,编译器的优化可能会检测到不需要帧指针,或者变量位置可以从堆栈指针推断出来(这在我们所从事的编译器中称为*帧指针模拟*技术),因此帧指针*不会*被压入堆栈。此外,编译器可能会检测到将变量(例如上面例子中的**i**)分配到寄存器而不是使用堆栈上的值效率要高得多,因此变量的初始值可能取决于许多因素(变量**i**显然是初始分配的,但如果*found*是变量呢?

除了仔细阅读代码和开启高级别的编译器诊断,没有静态分析工具的帮助,绝对无法检测到未初始化的局部变量。我特别喜欢 Gimpel Lint(参见 http://www.gimpel.com/),这是一个非常出色的工具,我强烈推荐。

边界错误

有许多有效的优化会揭示在调试版本中被掩盖的错误。是的,有时是编译器错误,但99%的情况下是真正的逻辑错误,在没有优化的情况下恰好无害,但在优化到位时却是致命的。例如,如果你有一个差一的数组访问,请考虑以下一般形式的代码

void func()
    {
     char buffer[10];
     int counter;

     lstrcpy(buffer, "abcdefghik"); // 11-byte copy, including NULL
     ...

在调试版本中,字符串末尾的`NULL`字节会覆盖计数器的高位字节,但除非 *counter* 变得 > 16M,否则即使 *counter* 处于活动状态,这也不会造成伤害。但在优化编译器中,*counter* 被移动到寄存器,并且从不出现在堆栈上。没有为它分配空间。`NULL`字节会覆盖 *buffer* 后面的数据,这可能是函数的返回地址,导致函数返回时发生访问错误。

当然,这对于各种布局的偶然特征都很敏感。如果程序是这样的话:

void func()
    {
     char buffer[10];
     int counter;
     char result[20];

     wsprintf(result, _T("Result = %d"), counter);
     lstrcpy(buffer, _T("abcdefghik")); // 11-byte copy, including NUL

那么 NUL 字节,它曾经覆盖了 *counter* 的高位字节(在这个例子中无关紧要,因为 *counter* 在使用它的那一行打印后显然不再需要),现在覆盖了 *result* 的第一个字节,结果是字符串 *result* 现在看起来是一个空字符串,而没有任何解释。如果 *result* 是一个 `char *` 变量或其他指针,你将会在尝试通过它访问时得到一个访问错误。然而程序“在调试版本中工作了”!嗯,它没有,它错了,但错误被掩盖了。

在这种情况下,你需要创建一个带调试信息的执行文件版本,然后使用“值改变时中断”功能来查找错误的覆盖。有时你必须非常有创意才能捕获这些错误。

我已经经历过。我曾经在每月公司会议上获得公司奖,因为我发现了一个致命的内存覆盖错误,这是一个“七级bug”,也就是说,一个被另一个有效(但错误)指针覆盖的指针导致另一个指针被覆盖,这导致索引计算错误,从而导致……七级损坏之后,它最终以致命的访问错误而崩溃。在该系统中,不可能生成带有符号的发布版本,所以我连续17个小时单步执行指令,通过链接映射反向工作,并逐渐追踪到它。我有两个终端,一个运行调试版本,一个运行发布版本。在调试版本中,当我找到错误后,显然是哪里出了问题,但在未优化的代码中,上面显示的现象掩盖了实际错误。

链接错误

链接类型

某些函数需要特定的 *链接类型*,例如 `__stdcall`。其他函数需要正确的参数匹配。最常见的错误可能在于使用了不正确的链接类型。当一个函数指定 `__stdcall` 链接时,你 *必须* 为你声明的函数指定 `__stdcall`。如果它 *没有* 指定 `__stdcall`,你 *不能* 使用 `__stdcall` 链接。请注意,你很少(如果曾有)看到一个“裸”的 `__stdcall` 链接被声明为这样。相反,有许多链接类型宏,例如 `WINAPI`、`CALLBACK`、`IMAGEAPI`,甚至古老(且明显过时)的 `PASCAL`,它们都是定义为 `__stdcall` 的宏。例如,`AfxBeginThread` 函数的顶级线程函数被定义为一个其原型使用 `AFX_THREADPROC` 链接类型的函数。

UINT (AFX_CDECL * AFX_THREADPROC)(LPVOID);

你可能会猜测这是一种 `CDECL`(即非 `__stdcall`)链接。如果你将线程函数声明为

UINT CALLBACK MyThreadFunc(LPVOID value)

并启动线程为

AfxBeginThread((AFX_THREAD_PROC)MyThreadFunc, this);

那么显式转换(通常是为了消除编译器警告而添加!)会欺骗编译器生成代码。这经常导致这样的疑问:“我的线程函数在线程完成时会使应用程序崩溃,但只在发布模式下。”为什么它在调试模式下不会这样做,我不得而知,但大多数时候,当我们查看问题时,都是线程函数上存在错误的链接类型。所以当你看到这样的崩溃时,请确保你所有链接都正确到位。警惕使用函数类型转换;相反,请将函数写成

AfxBeginThread(MyThreadFunc, (LPVOID)this);

这将允许编译器检查链接类型和参数数量。

参数计数

使用类型转换还会导致参数计数问题。这些问题中的大多数在调试模式下应该是致命的,但由于某些原因,其中一些直到发布版本才显现出来。特别是,任何以任何形式具有 `__stdcall` 链接的函数都必须具有正确数量的参数。通常这会在编译时立即显示出来,除非你使用了函数原型转换(例如上一节中的 `(AFX_THREADPROC)` 转换)来覆盖编译器的判断。这几乎总是在函数返回时导致致命错误。

最常见的情况是使用用户定义的消息。你有一条消息,它不使用 `WPARAM` 和 `LPARAM` 值,所以你写道:

wnd->PostMessage(UWM_MY_MESSAGE);

仅仅是为了发送消息。然后你编写一个看起来像这样的处理程序:

afx_msg void OnMyMessage(); // incorrect!

然后程序在发布模式下崩溃了。同样,我没有调查为什么这在调试模式下不会引起问题,但我们经常在创建发布版本时看到这种情况发生。用户定义消息的 *正确* 签名 *总是*,*无一例外*,是:

afx_msg LRESULT OnMyMessage(WPARAM, LPARAM);

你 *必须* 返回一个值,并且你 *必须* 拥有指定参数(如果你想要兼容64位世界,你 *必须* 使用 `WPARAM` 和 `LPARAM` 类型;那些“知道” `WPARAM` 意味着 `WORD` 并在 Win16 代码中简单地写 `(WORD, LONG)` 的人,在升级到 Win32 时付出了代价,因为它实际上是 `(UNSIGNED LONG, LONG)`,而在 Win64 中又会不同,那么为什么试图讨巧而做错呢?)

请注意,如果你不使用参数值,则无需为参数提供名称。因此,你为 `OnMyMessage` 编写的处理程序代码如下:

LRESULT CMyClass::OnMyMessage(WPARAM, LPARAM)
    {
     ...do something here...
     return 0; // logically void, 0, always
    }

编译器“错误”

一个优化编译器对其所处理的现实做出了一些假设。问题在于,编译器对现实的看法完全基于一系列假设,而C程序员很容易违反这些假设。这些对现实的错误描述导致你可以欺骗编译器生成“错误代码”。实际上并非如此;它是完全有效的代码,*前提是编译器所做的假设是正确的*。如果你对编译器撒谎了,无论是隐式还是显式,一切都完了。

别名错误

一个位置的 *别名* 是指向该位置的地址。通常,除非另有指示,编译器假定存在别名(这在C程序中很常见)。如果你告诉编译器可以假定没有别名,那么你可以获得更紧凑的代码,因此,它计算出的值在函数调用之间将保持不变。考虑以下示例

int n;
int array[100];
int main(int argc, char * argv)
    {
     n = somefunction();
     array[0] = n;
     for(int i = 1; i < 100; i++)
        array[i] = f(i) + array[0];
    }

这看起来相当简单;它计算一个`i`的函数,`f(i)`,目前我们不定义它,并向其中添加数组条目值。所以一个聪明的编译器会说,“看,`array[0]`在循环体中根本没有被修改,所以我们可以改变代码,将值存储在寄存器中并重新组织代码”

     register int compiler_generated_temp_001 =somefunction();
     n = compiler_generated_temp_001;
     array[0] = compiler_generated_temp_001;
     for(int i = 1; i < 100; i++)
        array[i] = f(i) + compiler_generated_temp_001;

这种优化,是循环不变式优化和值传播的结合,仅在`array[0]`未被`f(i)`修改的假设成立时才有效。但如果我们后来定义:

int f(int i)
   {
    array[0]++;
    return i;
   }

请注意,我们现在违反了`array[0]`是常量的假设;该值存在一个*别名*。现在这个别名相当容易看到,但当你拥有带有复杂指针的复杂结构时,你可能会得到完全相同的情况,但它在编译时无法检测到,也无法通过程序的静态分析检测到。

请注意,VC++ 编译器默认假定存在别名。你必须采取明确的行动来覆盖此假设。除了在非常有限的上下文中,这样做是一个坏主意;请参阅优化杂注的讨论。

const 和 volatile

这些是你可以在声明中添加的属性。对于变量声明,`const` 声明表示“此值永不改变”,而 `volatile` 声明表示“此值以你无法猜测的方式改变”。虽然在调试模式下编译时这些影响很小,但当你为发布版编译时它们会产生深远的影响,如果你未能正确使用它们,或者使用了错误,你将注定失败。

变量或函数上的 `const` 属性声明值是常量。这允许优化编译器对该值进行某些假设,并允许使用 *值传播* 和 *常量传播* 等优化。例如:

int array[100];
void something(const int i)
   {
    ... = array[i]; // usage 1
    // other parts of the function
    ... = array[i]; // usage 2
   }

const 声明允许编译器假定 i 的值在 usage 1 和 usage 2 点是相同的。此外,由于 array 是静态分配的,array[i] 的地址只需要计算一次;代码可以生成,就像它被写成一样:

int array[100];
void something(const int i)
   {
    int * compiler_generated_temp_001 = &array[i];
    ... = *compiler_generated_temp_001; // usage 1
    // other parts of the function
    ... = *compiler_generated_temp_001; // usage 2
   }

事实上,如果我们的声明是

const int array[100] = {.../* bunch of values */ }

代码可以生成,就像它是

void something(const int i)
   {
    int compiler_generated_temp_001 = array[i];
    ... = compiler_generated_temp_001; // usage 1
    // other parts of the function
    ... = compiler_generated_temp_001; // usage 2
   }

因此,**const** 不仅为您提供了编译时检查,还可以让编译器生成更小、更快的代码。请注意,您可以通过显式类型转换和各种巧妙的编程技巧强制违反 **const**。

volatile 声明与此类似,并且恰好相反:它表明对值的常数性*不能*做出任何假设。例如,这个循环

// at the module level or somewhere else global to the function
int n;
 // inside some function while(n > 0)   {
    count++;
   }

将被优化编译器轻松转换为

if(n > 0)
    for(;;)
       count++;

而 *这是一种完全有效的转换*。因为循环中 *没有任何* 东西可以改变 *n* 的值,所以没有理由再次测试它!这种优化是 *循环不变式计算* 的一个例子,优化编译器会将其“拉出”循环。

但如果程序的其余部分是这样呢

registerMyThreadFlag(&n);
while(n > 0)
    {
     count++;
    }

并且线程使用了通过`registerMyThreadFlag`调用注册的变量来设置传入地址的变量值?它将完全失败;循环将永远不会退出!

因此,必须通过向 *n* 的声明添加 `volatile` 属性来声明:

volatile int n;

这通知编译器,它不能随意假设值的恒定性。编译器将在每次循环迭代中生成代码来测试值**n**,因为你已明确告诉它假设值**n**是循环不变的是不安全的。

ASSERT 和 VERIFY

许多程序员在他们的代码中大量使用 **ASSERT** 宏。这通常是个好主意。**ASSERT** 宏的好处是,在发布版本中使用它不会花费你任何代价,因为宏有一个空的主体。简单来说,你可以将 **ASSERT** 宏的定义想象为:

#ifdef _DEBUG
#define ASSERT(x) if( (x) == 0) report_assert_failure()
#else
#define ASSERT(x)
#endif

(实际的定义更复杂,但细节在这里并不重要)。当你在做一些类似的事情时,这很好用:

ASSERT(whatever != NULL);

这很简单,并且在发布版本中省略测试的计算不会造成伤害。但是有些人会写这样的东西:

ASSERT( (whatever = somefunction() ) != NULL);

这在发布版本中会完全失败,因为赋值从未完成,因为没有生成代码(我们将把关于嵌入式赋值本质上是邪恶的讨论推迟到一篇尚未撰写的其他文章。请将其视为既定事实:如果你在if-test或任何其他上下文中编写赋值语句,你正在犯一个严重的编程风格错误!)

另一个典型的例子是

ASSERT(SomeWindowsAPI(...));

如果API调用失败,这将导致断言失败。但在系统的发布版本中,该调用从未执行!

这就是 `VERIFY` 的用途。想象一下 `VERIFY` 的定义是:

#ifdef _DEBUG
#define VERIFY(x) if( (x) == 0) report_assert_failure()
#else
#define VERIFY(x) (x)
#endif

请注意,这是一个非常不同的定义。在发布版本中删除的是 `if` 测试,但代码仍然执行。上面错误示例的正确形式是:

VERIFY((whatever = somefunction() ) != NULL);
VERIFY(SomeWindowsAPI(...));

这段代码在调试版本和发布版本中都能正常工作,但在发布版本中,如果测试结果为 `FALSE`,则不会出现 `ASSERT` 失败。请注意,我还见过这样的代码:

VERIFY( somevalue != NULL);

这简直是荒谬。它实际上意味着在发布模式下,它将生成代码来计算表达式但忽略结果。如果你开启了优化,编译器实际上足够聪明,可以确定你正在做一些没有意义的事情并丢弃本应生成的代码(但前提是你使用的是专业版或企业版编译器!)。但正如我们在本文中讨论的,你可以创建一个*未优化*的发布版本,在这种情况下,前面的 `VERIFY` 只会浪费时间和空间。

编译器 Bug(再次)

优化编译器是非常复杂的代码段。它们如此复杂,以至于通常没有人能够完全理解整个编译器。此外,优化决策可能以微妙和意想不到的方式相互作用。我已经经历过。

微软在优化编译器方面做了令人惊讶的良好质量保证工作。这并不是说它们是完美的,但它们实际上非常非常好。比我过去使用过的许多商业编译器都要好得多(我曾经使用过一个编译器,“优化”了一个常量`if`-test,当值为`TRUE`时执行`else`分支,当值为`FALSE`时执行`then`分支,他们告诉我们“我们会在明年某个时候的下个版本中修复它”。实际上,我认为他们在下个编译器发布之前就倒闭了,这对于任何曾经是客户的人来说都不足为奇)。

但“编译器错误”更有可能是你违反了编译器假设的结果,而不是真正的编译器错误。这是我的经验。

此外,它甚至可能不是影响你代码的 bug。它可能存在于MFC共享DLL或MFC静态链接库中,程序员犯了一个错误,该错误在这些库的调试版本中没有显现,但在发布版本中却出现了。同样,微软在测试这些方面做得非常好,但没有完美的测试程序。

尽管如此,《Windows Developer's Journal》(http://www.wdj.com/)每月都会刊登“本月Bug++”特写,其中包含真实、真正的编译器优化bug。

如何解决这些bug?最简单的技术是关闭所有编译器优化(参见下文)。如果你这样做,你很有可能*不会察觉到程序性能的任何差异*。优化只有在它重要时才重要。其余时间,它都是浪费精力(参见我关于此主题的MVP 技巧文章!)。在几乎所有情况下,对于几乎所有程序的几乎所有部分,经典优化都不再重要!

DLL 地狱

DLL 版本不一致的现象导致了所谓的“DLL 地狱”。即使是微软也这样称呼它。问题在于,如果微软 DLL A 需要微软 DLL B,那么它必须拥有正确版本的 DLL B。当然,所有这些都源于不想在每个发布版本中都将 DLL 重命名为包含修订级别和版本号或其他有用指示符的名称,但结果是生活变得相当难以忍受。

微软有一个网站,可以让你确定你的DLL组合是否一致。请访问 http://msdn.microsoft.com/library/techart/dlldanger1.htm 阅读有关此问题的文章。其中一个优点是,在Win2K和WinXP中,这种情况已大为改善。然而,有些问题仍然存在。有时,你的代码的发布版本会因不匹配而出现问题,而调试版本由于前面给出的所有原因更具弹性。

然而,还存在另一个潜在问题:混合使用共享 MFC 库的 DLL,并且既有调试版本又有发布版本。如果你使用自己的 DLL,并且它们使用共享 MFC 库,*请务必确保所有 DLL 都是调试版本或发布版本!*这意味着你永远、永远、在任何情况下都不要依赖 PATH 或隐式搜索路径来定位 DLL(我发现搜索路径的整个概念是一个考虑不周的蹩脚方案,它必然会导致这种行为;我*从不*依赖 DLL 加载路径,除非是为了从 %SYSTEM32% 加载标准微软 DLL,如果你使用任何超出此范围的搜索路径,你活该遇到任何问题!还要注意,你绝不能,在任何可以想象的情况下,将自己的 DLL 放入 %SYSTEM32% 目录。其一,Win2K 和 WinXP 无论如何都会删除它,因为“System32 锁定”,这是一个十年前就应该强制执行的好主意)。

不要以为“静态链接”MFC库能解决这个问题!实际上,它只会让问题变得更糟,因为你最终可能会得到*n*个互不相关的MFC运行时副本,每个副本都认为自己拥有整个世界。因此,DLL要么使用共享MFC库,要么完全不使用MFC(如果你拥有私有MFC库副本,可能会出现的问题多得不值得在一个普遍级的网页上提及,为了保护键盘,我不会在你们正在阅读时描述它们。好吧,举一个例子:MFC窗口句柄映射。你*真的*想要两个或更多个句柄映射副本,每个副本都可能对窗口句柄映射有不同的理解,并尝试协调程序的行为吗?我想不需要)。

然而,重要的是不要混用调试和发布版本的MFC DLL(请注意,一个“纯粹的”非MFC发布DLL可以从MFC程序的调试版本中调用;这在OLE、WinSock、多媒体等标准微软库中经常发生)。调试版本和发布版本的DLL与MFC的接口也足够不同(我没有详细查看,但我收到过有关问题的报告),你将会遇到`LoadLibrary`失败、访问故障等。

不太好看的景象。

避免这种情况的一种方法是让你的 DLL 子项目将 DLL 编译到主程序的 Debug 和 Release 目录中。我的做法是进入 DLL 子项目,选择“项目设置”,选择“链接”选项卡,然后在路径前面加上“**..\**”。你必须在 Debug 和 Release 配置(以及任何你可能有的自定义配置)中独立完成此操作。

我还手动编辑了命令行,在 **.lib** 文件的路径前加上“**..\**”,这样也更容易链接。

请注意下图中高亮显示的黄色区域。左上方显示我正在 **Debug** 配置中工作。右中显示我对输出文件的编辑,右下方显示我手动编辑以重定向 **.lib** 文件。

诊断技术

于是程序失败了,你却一头雾水,不知道为什么。嗯,有一些技巧可以尝试。

关闭优化

你可以做的一件事是关闭发布版本中的所有优化。进入发布版本的 **Project | Settings**,选择 C/C++ 选项卡,在组合框中选择 **Optimizations**,然后简单地关闭所有选项。然后执行 **Build | Rebuild All** 并再次尝试。如果 bug 消失了,那么你就有线索了。不,你仍然不知道它是否是严格意义上的优化 bug,但你现在知道你程序中的 bug 是优化转换的结果,这可能就像一个未初始化的栈变量一样简单,其未初始化的值对代码的优化敏感。帮助不大,但你现在知道的比以前更多。

打开符号

你可以调试程序的发布版本;只需进入“C/C++”选项卡,选择“常规”类别,然后选择“用于编辑和继续的程序数据库”。你还必须选择“链接”选项卡,在“常规”类别下,选中“生成调试信息”框。特别是,如果你关闭了优化,你将拥有与调试版本相同的调试环境,只是你使用的是非调试的MFC共享库,因此你无法单步调试库函数。如果你没有关闭优化,调试器可能会在变量值方面欺骗你,因为优化可能会在寄存器中复制变量而不会告诉调试器。调试优化代码可能*很难*,因为你无法确定调试器告诉你的内容是否准确,但有符号(和行回溯)总比没有好。请注意,语句可能会在优化版本中被重新排序、从循环中拉出、从未计算等,但目标是代码在语义上与未优化版本相同。你希望如此。但代码的重新排列有时会使调试器很难确定错误发生的精确行。对此做好准备。通常,一旦你大致知道要在哪里查找,你会发现错误是如此明显,以至于更详细的调试器信息并不关键。

选择性启用/禁用优化

您可以使用 **Project | Settings** 按文件选择性地更改项目的特性。我通常的策略是全局禁用所有优化(在发布版本中),然后一次一个地,仅在那些重要的模块中选择性地重新启用它们,直到问题再次出现。那时,您就对问题在哪里有了很好的了解。您还可以将 **pragma** 应用到项目中,以进行非常精细的优化控制。

不要优化

这里有一个问题要问:*这重要吗*?你手头有一个要发布的产品,一个客户群,一个截止日期,以及一些只在发布版本中出现的真正模糊的bug!为什么还要优化?它*真的*重要吗?如果它不重要,你为什么要浪费时间?只需关闭发布版本中的优化并重新编译。完成。没有麻烦,没有混乱。可能稍微大一点,稍微慢一点,但*这重要吗?*阅读我关于优化是你最大敌人的文章

只优化关键部分

一般来说,GUI代码几乎不需要优化,原因在我的文章中已经给出。但正如我在那篇文章中指出的,内部循环确实非常重要。有时你甚至可以在内部循环中选择性地启用优化,而你不敢在程序中全局启用,例如告诉某个例程不可能存在别名。为此,你可以围绕该例程应用优化pragma。

例如,在编译器帮助中查找“pragma”,以及子主题“affecting optimization”。您将找到一系列指向详细讨论的指针。

内联函数

如果编译器判断这样做能获得合适的收益,你可以让任何函数内联展开。只需在函数声明中添加 **inline** 属性。对于 C/C++,这要求函数体在头文件中定义,例如:

class whatever {
     public:
        inline getPointer() { return p; }
     protected:
        something * p;
    }

一个函数通常不会被内联编译,除非编译器被要求内联编译内联函数,并且它已经决定这样做是没问题的。请阅读手册中的讨论。启用内联展开优化的编译器开关通过 **Project | Settings** 设置,选择 C/C++ 选项卡,选择 **Optimizations** 类别,然后在 **Inline function expansion** 下拉列表中选择优化类型。通常,对于发布版本,使用 **/Ob1** 就足够了。请注意,如果你的 bug 再次出现,你就能很清楚地知道该去哪里查找。

内联函数

编译器知道某些函数可以直接展开为代码。隐式可内联的函数包括以下这些:

_lrotl, _lrotr, _rotl, _rotr, _strset, abs, fabs, labs, memcmp, memcpy, memset, strcat, strcmp, strcpy, and strlen

请注意,除非它已经处于程序中时间敏感的部分,否则隐式地将其中一个函数展开成代码几乎没有什么优势。记住那篇文章:测量,测量,再测量。

内联函数通常会使代码大小更大,尽管代码速度更快。如果你需要它,你可以简单地声明:

#pragma intrinsic(strcmp)

之后对 **strcmp** 的所有调用都将以内联代码形式展开。你还可以使用 **/Oi** 编译器开关,该开关通过 **Project | Settings**,C/C++ 选项卡,**Optimizations** 类别进行设置,如果你选择 **Custom**,则选择 **Generate Intrinsic Functions**。你可能永远不会遇到因为内联展开而在优化代码中出现的 bug。

请注意,无论如何,在您的代码中将 **strcmp** 编码为函数调用可能是一个严重失败的想法,如果您曾考虑构建应用程序的 Unicode 版本。您应该编写 **_tcscmp**,它在 ANSI(8位字符)应用程序中扩展为 **strcmp**,在 Unicode(16位字符)应用程序中扩展为 **_wcscmp**。

真正严格的控制

如果你有一个高性能的内部循环,你可能想告诉编译器一切都是安全的。首先,应用所有必要的 `const` 或 `volatile` 修饰符。然后开启单独的优化,例如:

#pragma optimize("aw", on)

这告诉编译器,它可以对不存在别名做出许多深层假设。结果将是*快得多*,*紧凑得多*的代码。*不要*全局地告诉编译器假定没有别名!你很可能会自毁,因为你系统地在各处违反了这一限制(这很容易做到,而且很难找到如果你做到了)。这就是为什么你只想在非常受限的上下文中进行这种优化,在那里你对正在发生的一切有完全的控制。

当我不得不这样做时,我通常会将函数移动到它自己的文件中,因此只有我想优化的函数才会受到影响。

摘要

本文概述了一些应对有时出现的调试版本与发布版本问题的原理和策略。最简单的通常是最好的:只需关闭发布版本中的所有优化。然后,只对你代码中可能重要的那1%部分选择性地开启优化。假设你的代码中有那么多部分是重要的。对于我们编写的许多应用程序,只有很少的代码是重要的,以至于优化和未优化代码的性能几乎相同。

其他参考文献

请查阅 http://www.cygnus-software.com/papers/release_debugging.html,了解 Bruce Dawson 提供的一些额外有用见解。他在此处提出的一个特别好的观点是,您应该*始终*为您的发布版本生成调试符号信息,这样您就可以实际调试产品中的问题。我不知道我为什么从未想到这一点,但我确实没有!


这些文章中表达的观点是作者的观点,不代表,也不被微软认可。

发送邮件至newcomer@flounder.com提出关于本文的问题或评论。
版权所有 © 1999 保留所有权利
www.flounder.com/mvp_tips.htm
© . All rights reserved.