原文地址:
http://www.cocoachina.com/ios/20150730/12830.html
WWDC 2015上,除了Swift 2.0外,还有一个令人激动的消息:可以直接在Xcode 7上使用Clang的地址消毒剂(Address Sanitizer)了。这篇文章中我们将详细讨论下这个功能,比如它是怎样工作的,以及使用的方法。这是Konstantin Gonikman提议的话题。
C语言中一种异常危险的情况
从很多方面来看,C语言都是一种伟大的编程语言。事实上,发明至今已逾40年,它仍然保持着强劲的势头。这足以说明它的伟大。这不是我学的第一门(也不是第二门)编程语言,但正是它,使我第一次真正揭开了计算机运行机制的神秘面纱。而且,它是我至今仍在使用的唯一语言。
然而,C也是一门非常危险的编程语言,代码世界中的许多痛苦由它而生。它造成了许多怪异的bug,这些bug其他的编程语言根本无法表述。
内存安全是一个主要的问题。C语言中根本没有内存安全可言。像下面的代码,会被正常的编译,而且可能正常运行:
1 2 |
|
这段代码只申请了5字节的数组空间,却通过指针写入数据到第13字节上。在这个地址上,隐藏的数据损坏可能发生,也可能平安无事(比如在Apple平台上,malloc函数总是最少分配16个字节,即使你申请少于16字节的空间,因此这段代码在Apple平台上运行正常,但不要依赖系统的这个特性)。这段错误代码可能危害不大,也可能后患无穷。
更“聪明”的语言跟踪数组的大小,在操作的时候会验证下标的有效性。同样的Java代码,会比较可靠地抛出异常。有了异常机制,调试这些“神奇”问题就容易的多了。例如,一个变量应该为4,但实际上它的值为5,我们就知道某段修改该变量值的代码出了问题(这样至少我们会集中注意力到程序调试上,而不会盯着编译器,因为它一般不会出错)。但是使用C语言,我们根本无法做出假设,bug有可能是某段代码“故意”修改变量值造成的,也可能是某段代码使用了“坏指针”无意中修改了变量值。
整个产业已经开始着手解决这个问题。例如,Clang的静态代码分析,可以从代码中查找特定类型的内存安全问题。如Valgrind之类的程序可以在运行时检测到不安全的内存访问。
Address Sanitizer是另外一种解决方案。它使用了一种新的方法,有利有弊。但仍不失为一个查找代码问题的有力工具。
内存访问验证
许多这类工具在运行时验证内存访问的有效性,从而查找到问题。理论依据是:访问内存时,通过比较访问的内存和程序实际分配的内存,验证内存访问的有效性,从而在bug发生时就检测到它们,而不会等到副作用产生时才有所察觉。
理想情况下,每个指针都会包含数据大小和指向内存的位置信息,因此可针对这些验证每次的内存访问。为何C编译器在设计之初没有加入验证的特性,还没有具体的原因。但附加在指针上的元数据会使程序无法兼容标准C编译器编译的代码。这就意味着无法简单使用系统库,必然严重限制了使用该体系检测代码。
Valgrind解决以上问题的方案是:在模拟器上运行整个程序。这样,就可以直接运行标准C编译器生成的二进制文件,而不需要做任何额外的修改。然后在程序运行的时候进行分析,检查程序处理的每一块内存。这样的方式可以使它高效运行所有程序,包括系统的库,而不做任何修改。这样做的代价是速度变得很慢,因此在一些效率要求高的程序中不实用。另外,这种方式需要深度了解某个平台系统调用的含义,
只有这样才能合适地追踪内存改变状态。因而必然需要针对特定宿主系统的深度整合。多年间,Valgrind对Mac的支持无明确计划。截止本文发布之时,它还不支持Mac 10.10。
保护性内存分配得益于CPU内置的内存检查工具。它取代了标准的malloc函数。使用时,每个分配内存结尾的后面会被标记为不可读写。当程序尝试访问后面的内存,会出错。这样的做法有一个弊端:硬件的内存保护精确度不够。内存只能在内存页尺度上被标记为可读或不可读,而在现代操作系统中,内存页至少有4kB空间。这意味着每次内存分配至少都需要占用8kB内存:一页内存用来存储数据,另外一页用来限制越界的内存访问。即使只申请几字节的内存,也需要这样做。另外,这样的做法也导致小规模的越界不会被检测到。为了储存针对标准malloc的内存的保护,需要分配内存到16字节的范围内,因此,若分配的内存大小不是16字节的整数倍,余出的几个字节将不受保护。
内存消毒剂机制尝试在更小的粒度上处理内存受限。在本质上,这样的内存分配保护机制较慢,但却更实用。
追踪受限内存
既然不能使用硬件层面的内存保护,就必须使用软件的手段来实现。因为通过指针无法传递额外数据,跟踪内存必须通过某种“全局表”来完成。这个表需要能被快速的读取和修改。
内存消毒剂使用了一种简单但是很巧妙的方法:它在进程的内存空间上保存了一个固定的区域,叫做“影子内存区”。用内存消毒剂的术语来说,一个被标记为受限的内存被称作“中毒”内存。“影子内存区”会记录哪些内存字节是中毒的。通过一个简单的公式,可以将进程中的内存空间映射到“影子内存区”中,即:每8字节的正常内存块映射到一个字节的影子内存上。在影子内存上,会跟踪这8字节的“中毒状态”。
每8字节的内存映射8位(1字节)的影子内存,我们自然会想到,每字节内存的“中毒状态”只能通过影子内存上的一位来标记的。然而实际情况是,内存消毒剂在跟踪内存状态时,每字节使用一个整型值来记录。它假定所有“中毒内存”块都是连续的,且顺序从后往前,因此可以使用影子内存的一个字节来表示正常内存块中“中毒”的内存数量。例如:0表示所有内存都是正常的;1表示最后一个字节有问题;2表示最后两个字节有问题,依次类推,7表示这几个字节都有问题。若所有8字节都“中毒”,这个值就为负。使用这样的方式,就可以在访问内存的时候进行检查。分配内存的起始位置一般来说不会太过接近,因此,假定“中毒”内存是连续的且从后往前的, 这样不会带来什么问题。
有了这个表结构,地址消毒剂在程序中生成额外的代码来检查每次使用指针的读写操作,并在内存中毒的状态下抛出错误。该特性被集成在编译器中,而不仅仅在外部库和运行环境中存在,这样带来了不少好处:每个指针访问可被可靠地标识,并将合适的内存检查添加到机器码中。
编译器集成还支持一些简洁的技巧, 比如,除了堆(heap)上分配的内存外,可以跟踪保护本地和全局变量。本地和全局内存分配时会产生一些间隔,这些间隔内存若“中毒”可能导致溢出。这一点上,保护式内存分配无能为力,Valgrind也疲于应对。
编译器集成的特性也有其缺点。详细来说,地址消毒剂无法捕捉系统库中的错误内存访问。当然,它和系统库是“兼容”的。当使用系统库的时候,你可以打开内存消毒剂功能。比如,你可以构建一个链接Cocoa的程序,正常运行它。但是它不会捕捉Cocoa造成的错误内存访问,也无法检测你的代码调用Cocoa时分配的内存。
内存消毒剂也能用来捕捉“释放后使用”的错误。内存在释放后都会被标记为“中毒”,之后无法对其再进行访问。“释放后使用”的错误在内存重用时危害不浅,因为那样你会破坏不相关的数据。内存消毒剂会将刚释放的内存放置到一个回收队列中,在一段时间内将无法申请到这些内存,从而在重用时避免这样的错误。自然,为每个指针访问添加检查代价不小。它取决于代码做了什么,因为不同类型的代码访问指针内容的频率各不相同。平均算来,内存检查会降低大概2~5倍的速度,这个开销挺大,但还不至于让程序无法使用。
如何使用?
在Xcode 7上使用Address Sanitizer很简单。当通过命令行编译时,需要给clang命令调用添加-fsanitize=address参数。下面是一个测试程序:
编译,通过Address Sanitizer运行:
程序立马crash,输出很多内容:
这里包含很多信息,真实场景中,这些信息将对跟踪问题产生巨大帮助。它不仅显示了错误内存写入的位置,还标识了内存初始分配的位置。另外,还有其他附加信息。
在Xcode中使用内存消毒剂更简单:编辑scheme,点击Diagnostics标签页,选中"Enable Address Sanitizer"选项。然后就可以正常构建、运行,然后就能查看到大量诊断信息。
附加特性:不明确行为消毒剂
错误的内存访问只是C语言中诸多“有趣”的不明确行为的一种。Clang还提供了其他的消毒剂,使用它可以捕捉许多不明确行为。以下是实例程序:
1 2 3 4 5 6 7 |
|
运行代码:
结果的最后有些怪异。毫无疑问,有符号整形值溢出是C语言中的不明确行为。若能将这个错误捕捉,而不是产生错误的数据,就再好不过了。不明确行为消毒剂能有所帮助,传递-fsanitize=undefined-trap -fsanitize-undefined-trap-on-error参数来开启它:
这里并不像地址消毒剂那样输出额外的信息,但是,在出现错误的时候,程序立即停止了执行,而且我们通过调试工具可以很简单地查找问题。
不明确行为消毒剂暂时未集成到Xcode中,但是你可以在工程的build settings中添加compiler flags来使用。
结论
Address Sanitizer是一个伟大的技术,可以帮助我们查找到很多C代码中的问题。它并不完美,不能查找到所有错误,但仍能提供非常有用的诊断信息。在这里,我强烈建议你在自己的代码中尝试使用它,你会发现令你吃惊的结果。