在托管代码环境之外,如何安全地进行编程?

8

如果您是使用C或C++编程的人,没有使用托管语言的内存管理、类型检查或缓冲区溢出保护,使用指针算术操作时,如何确保您的程序是安全的?您会使用许多单元测试,还是只是一个非常谨慎的编码者?您是否有其他方法?


8
C/C++具有内存管理(智能指针)。它是一种强类型语言。缓冲区保护是可选的(使用at()而不是operator[])。所以这并不像我们在使用石刀一样。 - Martin York
3
@Martin,我通常不会跟着整个“我要抱怨一个人使用了‘C/C++’”这种话题,但我认为在那个声明中仅仅说C++是值得的。 C没有标准方法来进行内存保护或缓冲区边界检查。 - Falaina
4
如果没有现代化的技术支持,编写可行的程序几乎是不可能的。这就是为什么操作系统会崩溃的原因。 - unwind
3
@Phil: 我几乎从不推荐使用strncpy()。它很奇怪,而且不像预期的那样安全。 - unwind
取消:在我看来,“n”函数(以及类似的函数)确实增加了代码的复杂性。您需要检查代码以查看缓冲区的大小,无论您是否使用“n”变量。使用std :: string吧。如果您仍在使用C而不是C ++,请停止。 - Tom Hawtin - tackline
显示剩余3条评论
9个回答

25

我会采用以下的方法:

  1. 非常谨慎
  2. 尽可能使用智能指针
  3. 使用经过测试的数据结构和许多标准库
  4. 一直进行单元测试
  5. 使用内存验证工具,如MemValidator和AppVerifier
  6. 每天晚上祈祷它不会在客户端崩溃。

实际上,我有点夸张了。如果您正确地组织代码,控制资源并不太难。

有趣的是,我有一个大型应用程序,它使用DCOM并具有托管和非托管模块。在开发过程中,非托管模块通常很难调试,但由于进行了许多测试,因此在客户端执行得非常好。托管模块有时会遭受糟糕的代码,因为垃圾收集器非常灵活,程序员变得懒惰,不检查资源使用情况。


2
我对在C++代码中看到裸指针产生了过敏反应。如果我看到一个,我的本能就是将其包装在智能指针中,即使这是不必要的。这种本能为我服务得很好——我不记得自己有悬空指针已经十年或更长时间了。 - philsquared
4
我认为大多数经验丰富的C++开发人员会认为,与正确使用智能指针相比,垃圾回收效率最好也只能算是低效的、在最坏情况下则是一个支撑。虽然有适用于C++的垃圾回收器,但由于存在高效的实现和各种智能指针的实现,它们并不受欢迎。显然,你对智能指针的理解似乎影响了你的观点。我建议进一步阅读如何以及何时使用它们(因为auto_ptr并非仅仅具有有限的用途,它有一个非常精确和明确定义的用途(所有权转移))。 - Martin York
6
“@SDX2000: 退役一门语言的概念是可笑的。每种语言都适用于解决不同应用领域的问题。C#/Java / C++ / C在不同(但有重叠)的领域中各有所长,以及其他一些领域它们可能不太有用。你不应该仅仅因为你会某种语言就使用它,你应该选择最适合你正在编写程序的问题领域的语言。” - Martin York
3
@Martin - 针对你的第二条评论,你说得对,确实令人发笑。当我说 C++ 应该退役时,我应该更具体一些。我的意思是... 现在到了重新评估 C++ 作为通用问题解决工具的地位,并停止在其他现代语言更好服务的领域中使用它的时候了。如果你曾经用过 C#,你就会知道 C++非常麻烦。我已经在用 C++ 编程 15 年了,我的 C++ 技巧在这里不成问题。 - Sandeep Datta
1
智能指针并不是什么“高效”的东西。相比于良好的垃圾回收机制,引用计数(假设我们讨论的是这种智能指针)非常低效。一个优秀的C++程序员应该接受这个事实。垃圾回收器非常高效,比我们在C++中使用的原始引用计数要高得多。当然,智能指针还有其他可取之处,而垃圾回收机制无法提供。但性能并不是其中之一。 - jalf
显示剩余4条评论

16

我使用很多的"asserts",并且同时构建了"debug"版本和"release"版本。由于所有的检查,我的debug版本运行速度比release版本慢得多。

我经常在Valgrind下运行,并且我的代码没有任何内存泄漏。零泄漏。保持程序无泄漏比修复所有泄漏的错误程序要容易得多。

此外,尽管我将编译器设置为额外警告,但我的代码仍可以编译通过,没有任何警告。有时这些警告是无意义的,但有时它们会直接指向一个错误,然后我就可以不用调试就把它修复了。

我正在编写纯C代码(这个项目不能使用C++),但我以非常一致的方式来做C编程。我有面向对象的类,有构造函数和析构函数;虽然我需要手动调用它们,但是这种一致性是有帮助的。如果我忘记调用析构函数,Valgrind就会让我头疼到我去解决它。

除了构造函数和析构函数,我还编写了一个自检函数,检查对象并决定它是否正常;例如,如果文件句柄为null但关联的文件数据没有清零,则表示存在某种错误(句柄可能被覆盖,或者文件没有被打开,但对象中的这些字段中有垃圾数据)。此外,我的大部分对象都有一个"signature"字段,必须设置为特定值(对于每个不同的对象都是特定的)。使用对象的函数通常会断言对象是正常的。

每当我使用malloc()分配内存时,我的函数会用0xDC值填充内存。未完全初始化的结构变得明显:计数太大,指针无效(0xDCDCDCDC),当我在调试器中查看结构时,它很明显是未初始化的。这比在调用malloc()时将内存清零要好得多。(当然,0xDC填充仅在调试版本中存在;不需要在发布版本中浪费时间。)
每当我释放内存时,我会擦除指针。这样,如果我有一个愚蠢的错误,代码尝试在内存被释放后使用指针,我立即得到一个空指针异常,指向我正确的错误。我的析构函数不接受对象指针,而是接受指向指针的指针,并在析构对象后破坏指针。此外,在释放对象之前,析构函数会擦除它们,因此如果某段代码拥有指针的副本并尝试使用对象,则健全性检查断言会立即触发。
Valgrind会告诉我任何代码是否写入了缓冲区的末尾。如果没有Valgrind,我会在缓冲区末尾放置“canary”值,并进行健全性检查以测试它们。这些canary值,就像签名值一样,仅在调试版本中存在,因此发布版本不会有内存膨胀。
我有一组单元测试,当我对代码进行任何重大更改时,运行单元测试并且有信心没有出现可怕的错误是非常令人安心的。当然,我会在调试版本和发布版本上运行单元测试,以便所有断言都有机会发现问题。
将所有这些结构放在一起需要额外的努力,但每天都会得到回报。当一个断言触发并直接指向一个错误时,我感到非常高兴,而不必在调试器中运行错误。从长远来看,始终保持清洁只是少做更多工作。
最后,我必须说我实际上喜欢匈牙利命名法。我几年前曾在微软工作过,并像 Joel 一样学习了 Apps Hungarian 而不是破损的变体。它确实让错误的代码看起来很奇怪

1
这听起来很不错...但我很高兴有像Eric Lippert这样的人帮我规划结构,而我不用动手。 - MarkJ

13

同样重要的是 - 您如何确保文件和套接字已关闭、锁已释放等。内存不是唯一的资源,在使用GC时,您会失去可靠/及时的销毁。

无论是GC还是非GC都不是自动优越的。每种方法都有其优点和代价,一个好的程序员应该能够处理两种方法。

我在回答这个问题时也提到了这一点。


有一些在托管语言中实现RAII的技术:http://www.levelofindirection.com/journal/2009/9/24/raii-and-closures-in-java.html http://www.levelofindirection.com/journal/2009/9/24/raii-and-readability-in-c.html - philsquared
...和http://www.levelofindirection.com/journal/2009/9/24/raii-and-closures-in-java.html - philsquared
1
@Phil - 很有趣的阅读,但是当然任何认为“这证明C#和Java胜过C++”的人都应该实际阅读那些链接。如果成语是一种神奇的治疗方法,确保在C++中正确删除堆分配对象的习惯用语也会是神奇的治疗方法,我们将不会看到垃圾收集粉丝嘲笑C++。 - user180247
1
套接字和文件锁定是一个误导。在托管语言中,有简单而成熟的模式可以处理这些问题。在C#中,使用“using”语句即可,在不再需要资源时自动释放资源。 - Robert Harvey
@Harvey - 并不是每个套接字或文件都只存在于单个函数调用的生命周期内 - 在这种情况下,使用封装的RAII的C++本地变量比try/finally更清晰且更不容易出错。例如,考虑支持GUI应用程序文档的文件,您可能希望保持打开状态(例如进行锁定)。您可能有多个视图对象引用该文档。已经涉及到与GC和RAII都有关的问题。在两种情况下,都有习惯用语来确保完成部分工作,但程序员必须正确应用这些习惯用语并通常要承担责任。 - user180247
显示剩余3条评论

3

我已经使用C++十年了。我还用过C、Perl、Lisp、Delphi、Visual Basic 6、C#、Java和其他许多语言,但我无法想起来。

回答你的问题很简单:你必须知道自己在做什么,比C#/Java更重要。这个更重要是Jeff Atwood关于“Java学校”的愤怒发泄的原因。

在某种程度上,你提出的大部分问题都是无意义的。你提出的“问题”只是硬件运作的事实。我想挑战你用VHDL / Verilog编写CPU和RAM并看看它们如何工作,即使是真正简化的版本。你会开始欣赏C#/Java方式是对硬件的抽象。

一个更容易的挑战是为嵌入式系统编写一个基本的操作系统,从初始上电开始;这也将向你展示需要了解的内容。

(我也写过C#和Java)


提问是达到“知道你在做什么”的过程的一部分。 - Robert Harvey
我并不是在抨击你,Robert。我已经尽力向你解释如何在虚拟机代码之外安全地编程,并提供了理解真实机器的途径。 - Paul Nathan
我很欣赏这一点,而且C/C++在嵌入式系统中被广泛使用;显然它比一些其他语言如Java更接近底层。 - Robert Harvey

3
我们在嵌入式系统中使用C语言进行编写。除了使用一些通用于任何编程语言或环境的技术之外,我们还采用以下方法:
  • 静态分析工具(例如PC-Lint)。
  • 遵循MISRA-C标准(由静态分析工具强制执行)。
  • 完全不使用动态内存分配。

2
安德鲁的回答很好,但我还想补充一点纪律性。我发现在足够多的C++练习之后,你会对什么是安全的和什么是让迅猛龙来吃你的有一个相当好的感觉。你往往会开发出一种编码风格,当遵循安全实践时感到舒适,并且如果您尝试将智能指针转换回裸指针并将其传递给其他内容,则会让您感到不安。

我喜欢把它看作是车间里的电动工具。一旦您学会了正确使用它并始终遵守所有安全规则,它就足够安全。当您认为可以放弃安全眼镜时,您会受伤。


1

除了这里提供的许多好建议之外,我最重要的工具是DRY——不要重复自己。我不会在我的代码库中散布容易出错的代码(例如用malloc()和free()处理内存分配)。我在代码中只有一个地方调用malloc和free,即在包装函数MemoryAlloc和MemoryFree中。

通常,在调用malloc时,需要进行所有参数检查和初始错误处理,并将其作为重复的样板代码放置在周围。此外,它使得任何需要修改一个位置的东西都能够实现,从简单的调试检查(如计算成功调用malloc和free的次数,并在程序终止时验证两个数字是否相等)到各种扩展安全检查。

有时,当我在这里读到像“我总是必须确保strncpy终止字符串,有没有替代方法?”这样的问题时。

strncpy(dst, src, n);
dst[n-1] = '\0';

经过数天的讨论,我一直在想,将重复功能提取到函数中的艺术是否已成为高级编程中不再教授的失传技艺。
char *my_strncpy (dst, src, n)
{
    assert((dst != NULL) && (src != NULL) && (n > 0));
    strncpy(dst, src, n);
    dst[n-1] = '\0';
    return dst;
}

代码重复的主要问题已解决 - 现在让我们想想,strncpy是否真的是适合该工作的正确工具。性能?过早优化!一旦它被证明是瓶颈,就可以从一个单一位置开始。


1

我已经学过C++和C#,但我不明白为什么管理代码如此受欢迎。

哦对了,有一个垃圾回收器来管理内存,这很有帮助...除非你在C++中使用普通指针,如果你只使用智能指针,那么问题就不会太多。

但是我想知道...你的垃圾回收器是否可以保护你免受以下问题的困扰:

  • 保持数据库连接打开?
  • 保持文件锁定?
  • ...

资源管理比内存管理复杂得多。好处是,C++让你快速学习资源管理和RAII的含义,以至于它成为一种反应:

  • 如果我想要一个指针,我想要一个auto_ptr、shared_ptr或weak_ptr
  • 如果我想要一个DB连接,我想要一个对象“Connection”
  • 如果我打开一个文件,我想要一个对象“File”
  • ...

至于缓冲区溢出,我们并不是到处都在使用char*和size_t。我们确实有一些东西叫做“string”、“iostream”,当然还有已经提到的vector::at方法,这些都使我们摆脱了这些限制。

已经测试过的库(stl、boost)很好,使用它们并着手解决更多功能性问题。


2
数据库连接和文件锁是一个误导。在托管语言中,这些都有简单、成熟的模式。在C#中,使用"using"语句可以自动处理资源释放,当它们不再需要时。 - Robert Harvey
2
在我看来,C++中智能指针的主要问题是缺乏真正的标准。如果你使用第三方库/框架,它们很可能不会都使用相同的智能指针类型。因此,你可以在一个模块内依赖于它们,但一旦你从不同供应商的组件进行接口交互,你就需要手动管理内存了。 - Niki
@nikie:当我使用第三方组件时,我希望它们在内存管理策略上非常清晰明了。但是,我们工作中唯一拥有的第三方库都是开源的,例如Boost或Cyptopp,所以我在这方面没有太多经验。 - Matthieu M.

0

C++拥有你提到的所有功能。

它有内存管理。你可以使用智能指针进行非常精确的控制。或者有一些垃圾回收器可用,尽管它们不是标准的一部分(但在大多数情况下,智能指针已经足够)。

C++是一种强类型语言。就像C#一样。

我们正在使用缓冲区。您可以选择使用接口的边界检查版本。但是,如果您知道没有问题,则可以自由使用接口的未检查版本。

将方法at()(已检查)与运算符[](未检查)进行比较。

是的,我们使用单元测试。就像您应该在C#中使用一样。

是的,我们是谨慎的编码者。就像您在C#中应该是一样的。唯一的区别是这两种语言的陷阱是不同的。


我没有看到有人问“C++是否具有现代内存管理的好处”,但是有人问:“如果您在C++中编程,没有现代内存管理的好处,...,您如何确保程序安全?” - Pete Kirkham
如果我不使用智能指针进行编程,那么确保程序安全就会变得更加困难。但我并不认为这很相关。假如你在使用C#进行编程时没有使用“using”语句(如果我没记错的话,这是一个相当近期的添加),你怎样确保其他资源被正确地释放? - David Thornley
智能指针难道不足以胜任VB6和COM引用计数所适用的相同情况吗?这正是微软选择.NET垃圾回收风格时想要改进的内容。 - MarkJ
@MarkJ: 几乎不会。 COM 引用计数把责任放在用户身上。像 GC 这样的智能指针则把责任放在智能指针/GC 的开发者身上。基本上,智能指针是一种更精细的垃圾回收机制,是确定性的(不像 GC 是不确定性的)。 - Martin York
@MarkJ:在Java中,GC会带来许多其他问题,使析构函数(或finalisers)实际上无用,而在.NET中,它们不得不添加“using”的概念使垃圾回收可用。因此,真正的问题是,为什么您认为“using”概念比“智能指针”更好,因为“using”将责任放回到对象的用户身上,就像COM引用计数所做的那样。 - Martin York
阅读此链接:https://dev59.com/pHNA5IYBdhLWcg3wKam3#1064485,了解智能指针及其GC优势的更详细描述。 - Martin York

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接