C程序设计中,内存操作相关的错误可以说是最常见,同时也是非常隐蔽的一类错误。这类错误往往导致程序莫名其妙地崩溃、耗尽系统资源,或是形成严重的安全弱点。
在 FreeBSD,以及多数其他 BSD 派生的系统中,重复 free() 在默认情况下都会导致 C 函数库调用 abort() 终止程序。除了 malloc(3) 函数族本身的设计之外,这也是一项非常重要的安全特性。与此相反,包括 *BSD 在内的多数系统的 C 函数库并不对堆进行审计,也就是说,从 API 设计者的观点来看,内存泄漏并不被认为是非常严重的程序设计问题。
为什么会有这样的区别呢?事实上,内存泄漏同样可以导致比较严重的问题,例如响应速度变慢、进程由于占用的资源太多而被 OS 杀掉导致 DoS 等等。为了回答这个问题,我们来观察一下两种问题出现的场景。
内存泄漏 是指这样一种场景:程序分配了一块内存,但已经不再持有引用这块内存的对象(通常是指针)。从 OS 的角度,它知道进程持有的内存数量;然而,从进程的角度,它可能并不完全知道自己持有哪些内存。
换言之,内存泄漏就是通过遍历进程内所有可以从栈上,或以静态变量形式存于堆上的指针及其后继,无法到达所有全部已分配内存的情形。
如果程序不存在其他问题(例如缓冲区溢出),此时程序访问内存时,任何时候都不会在无意中覆写超出范围的数据。即,将数据覆写到程序其他部分保存数据的内存单元。
而 重复释放 则指这样一种场景:程序分配一块内存之后,经过使用将这块内存释放,但并没有将指向这块内存的所有指针抹零或回收,并在其他部分再次将指向同一块内存单元的指针交给内存分配器去进行释放操作。这种情况下,我们可以断言:
1、程序逻辑并不清楚这块内存已经被释放;
2、有理由相信,对这块内存进行的写操作,可能已经影响了程序其他部分的行为,因为这块内存可能已经分配作为其他用途。
因此,这应被看作立即停止程序运行的一项致命错误,因为程序行为已经出现了异常,而C函数库拥有的信息不足以纠正这种异常行为,而另一方面,程序可能已经发生了堆缓冲区溢出。
为了削弱这类问题带来的实质性安全影响,现代的内存分配器往往会将尺寸接近的内存块放在一起(这样做还能够抑制内存碎片的产生,并提高CPU的数据缓存命中率,因为通常程序会倾向于一次性地访问相近的内存结构)。
从而能够在一定程度上减轻由于向已经释放的内存块继续写数据导致的损害(因为这些内存很可能被分配给同样的数据结构,这类写操作的危害往往会低于向其他类型的数据结构写数据,特别是当这些数据中包含一部分用户输入的时候)。
当然,彻底消除这类问题,需要为程序设计语言增加一些新的基础设施(例如强类型、托管内存等)。现代程序设计语言如Java、Python和.net系列等,都采用了避免这类问题的措施。然而,也正因为如此,通过这些语言入门并准备撰写 C 程序的开发人员就更需要注意这类问题。
【编辑推荐】