本文将带您了解一些良好的和内存相关的编码实践以将内存错误保持在控制范围内内存错误是 C 和 C++ 编程的祸根它们很普遍认识其严重性已有二十多年但始终没有彻底解决它们可能严重影响应用程序并且很少有开发团队对其制定明确的管理计划但好消息是它们并不怎么神秘引言 C 和 C++ 程序中的内存错误非常有害它们很常见并且可能导致严重的后果来自计算机应急响应小组(请参见参考资料)和供应商的许多最严重的安全公告都是由简单的内存错误造成的自从 年代末期以来C 程序员就一直讨论此类错误但其影响在 年仍然很大更糟的是如果按我的思路考虑当今的许多 C 和 C++ 程序员可能都会认为内存错误是不可控制而又神秘的顽症它们只能纠正无法预防 但事实并非如此本文将让您在短时间内理解与良好内存相关的编码的所有本质 正确的内存管理的重要性 存在内存错误的 C 和 C++ 程序会导致各种问题如果它们洩漏内存则运行速度会逐渐变慢并最终停止运行;如果覆盖内存则会变得非常脆弱很容易受到恶意用户的攻击从 年着名的莫里斯蠕虫攻击到有关 Flash Player 和其他关键的零售级程序的最新安全警报都与缓沖区溢出有关大多数计算机安全漏洞都是缓沖区溢出Rodney Bates 在 年写道 在可以使用 C 或 C++ 的地方也广泛支持使用其他许多通用语言(如 Java?RubyHaskell C#PerlSmalltalk 等)每种语言都有众多的爱好者和各自的优点但是从计算角度来看每种编程语言优于 C 或 C++ 的主要优点都与便于内存管理密切相关与内存相关的编程是如此重要而在实践中正确应用又是如此困难以致于它支配着面向对象编程语言功能性编程语言高级编程语言声明性编程语言和另外一些编程语言的所有其他变量或理论 与少数其他类型的常见错误一样内存错误还是一种隐性危害它们很难再现症状通常不能在相应的源代码中找到例如无论何时何地发生内存洩漏都可能表现为应用程序完全无法接受同时内存洩漏不是显而易见 因此出于所有这些原因需要特别关注 C 和 C++ 编程的内存问题让我们看一看如何解决这些问题先不谈是哪种语言 内存错误的类别 首先不要失去信心有很多办法可以对付内存问题我们先列出所有可能存在的实际问题 内存洩漏 错误分配包括大量增加 free()释放的内存和未初始化的引用 悬空指针 数组边界违规 这是所有类型即使迁移到 C++ 面向对象的语言这些类型也不会有明显变化;无论数据是简单类型还是 C 语言的 struct或 C++ 的类C 和 C++ 中内存管理和引用的模型在原理上都是相同的以下内容绝大部分是纯 C语言对于扩展到 C++ 主要留作练习使用 内存洩漏 在分配资源时会发生内存洩漏但是它从不回收下面是一个可能出错的模型(请参见清单 ) 清单 简单的潜在堆内存丢失和缓沖区覆盖 void f(char *explanation) { char p; p = malloc(); (void) sprintf(p The f error occurred because of %s explanation); local_log(p); } 您看到问题了吗?除非 local_log()对 free()释放的内存具有不寻常的响应能力否则每次对 f的调用都会洩漏 字节在记忆棒增量分发数兆字节内存时一次洩漏是微不足道的但是连续操作数小时后即使如此小的洩漏也会削弱应用程序 在实际的 C 和 C++ 编程中这不足以影响您对 malloc()或 new的使用本部分开头的句子提到了资源不是仅指内存因为还有类似以下内容的示例(请参见清单 )FILE句柄可能与内存块不同但是必须对它们给予同等关注 清单 来自资源错误管理的潜在堆内存丢失 int getkey(char *filename) { FILE *fp; int key; fp = fopen(filename r); fscanf(fp %d &key); return key; } fopen的语义需要补充性的 fclose在没有 fclose()的情况下C 标准不能指定发生的情况时很可能是内存洩漏其他资源(如信号量网络句柄数据库连接等)同样值得考虑 内存错误分配 错误分配的管理不是很困难下面是一个示例(请参见清单 ) 清单 未初始化的指针 void f(int datum) { int *p; /* Uhoh! No one has initialized p */ *p = datum;
} 关于此类错误的好消息是它们一般具有显着结果在 AIX 下对未初始化指针的分配通常会立即导致 segmentation fault错误它的好处是任何此类错误都会被快速地检测到;与花费数月时间才能确定且难以再现的错误相比检测此类错误的代价要小得多 在此错误类型中存在多个变种free()释放的内存比 malloc()更频繁(请参见清单 ) /* Allocate once free twice */ void f() { char *p; p = malloc();
free(p);
free(p); } /* Allocate zero times free once */ void f() { char *p; /* Note that p remains uninitialized here */ free(p); } 这些错误通常也不太严重尽管 C 标准在这些情形中没有定义具体行为但典型的实现将忽略错误或者快速而明确地对它们进行标记;总之这些都是安全情形 悬空指针 悬空指针比较棘手当程序员在内存资源释放后使用资源时会发生悬空指针(请参见清单 ) 清单 悬空指针 void f() { struct x *xp; xp = (struct x *) malloc(sizeof (struct x)); xpq = ;
free(xp);
/* Problem! Theres no guarantee that the memory block to which xp points hasnt been overwritten */ return xpq; } 传统的调试难以隔离悬空指针由于下面两个明显原因它们很难再现 即使影响提前释放内存范围的代码已本地化内存的使用仍然可能取决于应用程序甚至(在极端情况下)不同进程中的其他执行位置 悬空指针可能发生在以微妙方式使用内存的代码中结果是即使内存在释放后立即被覆盖并且新指向的值不同于预期值也很难识别出新值是错误值悬空指针不断威胁着 C 或 C++ 程序的运行状态 数组边界违规 数组边界违规十分危险它是内存错误管理的最后一个主要类别回头看一下清单 ;如果 explanation的长度超过 则会发生什么情况?回答难以预料但是它可能与良好情形相差甚远特别是C 复制一个字符串该字符串不适于为它分配的 个字符在任何常规实现中超过的字符会覆盖内存中的其他数据内存中数据分配的布局非常复杂并且难以再现所以任何症状都不可能追溯到源代码级别的具体错误这些错误通常会导致数百万美元的损失 内存编程的策略 勤奋和自律可以让这些错误造成的影响降至最低限度下面我们介绍一下您可以采用的几个特定步骤;我在各种组织中处理它们的经验是至少可以按一定的数量级持续减少内存错误 编码风格 编码风格是最重要的我还从没有看到过其他任何作者对此加以强调影响资源(特别是内存)的函数和方法需要显式地解释本身下面是有关标头注释或名称的一些示例(请参见清单 ) 清单 识别资源的源代码示例 /******** * * * Note that any function invoking protected_file_read() * assumes responsibility eventually to fclose() its * return value UNLESS that value is NULL * ********/ FILE *protected_file_read(char *filename) { FILE *fp; fp = fopen(filename r); if (fp) {
} else {
} return fp; } /******** ** Note that the return value of get_message points to a* fixed memory location Do NOT free() it; remember to* make a copy if it must be retained *********/ char *get_message() { static char this_buffer[];
(void) sprintf(this_buffer ); return this_buffer; } /******** * * While this function uses heap memory and so * temporarily might expand the overall memory * footprint it properly cleans up after itself * ********/ int f(char *item) { my_class c; int result;
c = new my_class(item);
result = cx; delete c; return result; } /******** * * Note that f() is documented to return a value * which needs to be returned to heap; as f thinly * wraps f any code which invokes f() must be * careful to free() the return value * ********/ int *f() { int *p; p = f();
return p; } 使这些格式元素成为您日常工作的一部分可以使用各种方法解决内存问题 专用库 语言 软件工具 硬件检查器在这整个领域中我始终认为最有用并且投资回报率最大的是考虑改进源代码的风格它不需要昂贵的代价或严格的形式;可以始终取消与内存无关的段的注释但影响内存的定义当然需要显式注释添加几个简单的单词可使内存结果更清楚并且内存编程会得到改进 我没有做受控实验来验证此风格的效果如果您的经历与我一样您将发现没有说明资源影响的策略简直无法忍受这样做很简单但带来的好处太多了 检测 检测是编码标准的补充二者各有裨益但结合使用效果特别好机灵的 C 或 C++ 专业人员甚至可以浏览不熟悉的源代码并以极低的成本检测内存问题通过少量的实践和适当的文本搜索您能够快速验证平衡的 *alloc()和 free()或者 new和 delete的源主体人工查看此类内容通常会出现像清单 中一样的问题 清单 棘手的内存洩漏 static char *important_pointer = NULL; void f() { if (!important_pointer) important_pointer = malloc(IMPORTANT_SIZE);
if (condition) /* Ooops! We just lost the reference important_pointer already held */ important_pointer = malloc(DIFFERENT_SIZE);
} 如果 condition为真简单使用自动运行时工具不能检测发生的内存洩漏仔细进行源分析可以从此类条件推理出证实正确的结论我重复一下我写的关于风格的内容尽管大量发布的内存问题描述都强调工具和语言对于我来说最大的收获来自软的以开发人员为中心的流程变更您在风格和检测上所做的任何改进都可以帮助您理解由自动化工具产生的诊断 静态的自动语法分析 当然并不是只有人类才能读取源代码您还应使静态语法分析成为开发流程的一部分静态语法分析是 lint严格编译和几种商业产品执行的内容扫描编译器接受的源文本和目标项但这可能是错误的症状 希望让您的代码无 lint尽管 lint已过时并有一定的局限性但是没有使用它(或其较高级的后代)的许多程序员犯了很大的错误通常情况下您能够编写忽略 lint的优秀的专业质量代码但努力这样做的结果通常会发生重大错误其中一些错误影响内存的正确性与让客户首先发现内存错误的代价相比即使对这种类别的产品支付最昂贵的许可费也失去了意义清除源代码现在即使 lint标记的编码可能向您提供所需的功能但很可能存在更简单的方法该方法可满足 lint并且比较强键又可移植 内存库 补救方法的最后两个类别与前三个明显不同前者是轻量级的;一个人可以容易地理解并实现它们另一方面内存库和工具通常具有较高的许可费用对部分开发人员来说它们需要进一步完善和调整有效地使用库和工具的程序员是理解轻量级的静态方法的人员可用的库和工具给人的印象很深其作为组的质量很高但是即使最优秀的编程人员也可能会被忽略内存管理基本原则的非常任性的编程人员搅乱据我观察普通的编程人员在尝试利用内存库和工具进行隔离工作时也只能感到灰心 由于这些原因我们催促 C 和 C++ 程序员为解决内存问题先了解一下自己的源在这完成之后才去考虑库 使用几个库能够编写常规的 C 或 C++ 代码并保证改进内存管理Jonathan Bartlett 在 developerWorks 的 评论专栏中介绍了主要的候选项可以在下面的参考资料部分获得库可以解决多种不同的内存问题以致于直接对它们进行比较是非常困难的;这方面的常见主题包括垃圾收集智能指针和智能容器大体上说库可以自动进行较多的内存管理这样程序员可以犯更少的错误 我对内存库有各种感受他们在努力工作但我看到他们在项目中获得的成功比预期要小尤其在 C 方面我尚未对这些令人失望的结果进行仔细分析例如业绩应该与相应的手动内存管理一样好但是这是一个灰色区域——尤其在垃圾收集库处理速度缓慢的情况下通过这方面的实践得出的最明确的结论是与 C 关注的代码组相比C++ 似乎可以较好地接受智能指针 内存工具 开发真正基于 C 的应用程序的开发团队需要运行时内存工具作为其开发策略的一部分已介绍的技术很有价值而且不可或缺在您亲自尝试使用内存工具之前其质量和功能您可能还不了解 本文主要讨论了基于软件的内存工具还有硬件内存调试器;在非常特殊的情况下(主要是在使用不支持其他工具的专用主机时)才考虑它们 市场上的软件内存工具包括专有工具(如 IBM Rational Purify 和 Electric Fence)和其他开放源代码工具其中有许多可以很好地与 AIX 和其他操作系统一起使用 所有内存工具的功能基本相同构建可执行文件的特定版本(很像在编译时通过使用 g标记生成的调试版本)练习相关应用程序和研究由工具自动生成的报告请考虑如清单 所示的程序 清单 示例错误 int main() { char p[]; strcpy(p Hello world); puts(p); } 此程序可以在许多环境中运行它编译执行并将Hello world\n打印到屏幕使用内存工具运行相同应用程序会在第四行产生一个数组边界违规的报告在了解软件错误(将十四个字符复制到了只能容纳五个字符的空间中)方面这种方法比在客户处查找错误症状的花费小得多这是内存工具的功劳 结束语 作为一名成熟的 C 或 C++ 程序员您认识到内存问题值得特别关注通过制订一些计划和实践可以找到控制内存错误的方法学习内存使用的正确模式快速发现可能发生的错误使本文介绍的技术成为您日常工作的一部分您可以在开始时就消除应用程序中的症状否则可能要花费数天或数周时间来调试 |