试着回忆一下您在第一份软件工作的第一天。您还记得人力资源部的人员完成入职手续后,您被要求做什么吗?您被要求编写一段全新的代码吗?可能不是。更有可能的是,您被要求修复一个或多个错误,并尝试理解大量、文档记录不良的源代码集合。
当然,这种情况不仅仅发生在应届毕业生身上;每当我们开始一份新工作或查看一段新代码时,这种情况都会发生在我们所有人身上。凭借经验,我们都发展出一套处理大型、陌生的源代码库的技术。这就是我所说的代码探险。
代码探险与其他工程实践非常不同,因为它是在系统的初始设计和实现很久之后进行的。它是一套在“犯罪”发生后使用的法医技术。
代码探险者需要提出几个问题,并且有工具可以帮助他们回答这些问题。我将研究其中的一些工具,指出它们的缺点并提出可能的改进。
源代码库已经很大了,而且一直在变得更大。表1显示了一些流行的开源软件的规模。Linux内核,支持17种不同的处理器架构,由642个目录、12,417个文件和超过500万行代码组成。像Apache这样复杂的网络服务器由28个目录和471个文件组成——包含超过158,000行代码——而像nvi编辑器这样的应用程序包含29个目录、265个文件和超过77,000行代码。我认为这些例子相当真实地代表了人们在开始工作时所面临的情况。
当然,存在更大的系统用于科学、军事和金融应用,但表1中显示的系统更熟悉,应该有助于传达对我们每天都接触到的系统的复杂性的本能感受。
不幸的是,我们可用的工具往往落后于我们试图探索的代码。造成这种情况的原因有几个,但主要原因是很少有公司能够依靠工具建立蓬勃发展的业务。销售对广泛受众有吸引力的软件要容易得多,而软件工具并非如此。
静态与动态。 我们可以使用两个尺度来比较代码探险工具和技术。第一个尺度范围从一端的静态分析到另一端的动态分析。在静态分析中,您不是观察正在运行的程序,而只是检查源代码。一个明显的例子是使用find、grep和wc工具来更好地了解源代码的大小(就像制作表1所做的那样)。代码审查是静态技术的一个例子。您打印代码,坐在桌子旁,然后开始阅读。
表 1 流行软件的规模 | ||||
程序 | 版本 | 目录 | 文件 | 行数 |
Apache Web 服务器 | 1.3 | 28 | 471 | 158,332 |
DB | 1.4.25 | 176 | 234 | 99,836 |
Emacs | 21 | 43 | 2586 | 1,317,915 |
FreeBSD 内核 | 5.1 | 420 | 4758 | 2,140,517 |
Linux 内核 | 2.4.20-8 | 642 | 12,417 | 5,223,290 |
Nvi | 1.81.5 | 29 | 265 | 77,176 |
Python | 2.2.3 | 245 | 1158 | 356,314 |
注意:已尽一切努力忽略与源代码不直接相关的文档或其他文件。 |
在动态尺度的一端是调试器等工具。在这里,您在真实数据上运行代码,使用一个程序(调试器)来检查另一个程序的执行情况。另一个动态工具是软件示波器,它将多线程程序显示为一系列水平时间线——就像真实示波器上的那些——以查找死锁、优先级倒置以及多线程程序常见的其他错误。示波器主要用于嵌入式系统。
蛮力与精妙。 第二种类型的尺度衡量应用于探险的精妙程度。一个极端是蛮力方法,它通常消耗大量的CPU时间或可能生成大量数据。蛮力方法的一个例子是尝试通过使用grep查找错误发生位置附近打印的消息来查找错误。
在精妙尺度的另一端是微妙。查找程序中字符串的微妙方法涉及一种工具,该工具构建代码库中所有有趣组件的数据库(例如,函数名称、结构定义等)。然后,每次从代码存储库更新源代码时,您都使用该工具生成新的数据库。当您想了解源代码的某些信息时,您也会使用它,因为它已经掌握了您需要的信息。
绘制您的方法。 您可以使用这两个尺度创建一个二维图,其中 x 轴表示精妙程度,y 轴从静态到动态。然后,您可以将工具和技术绘制在这个图上,以便进行比较(见图 1)。使用的任何术语都不暗示对技术的价值判断。有时,您想要的恰恰是静态的蛮力方法。如果设置工具进行一些微妙的分析需要同样长的时间(或更长时间),那么蛮力方法是有意义的。在代码探险中,您通常不会因为风格而得分;人们只是想要结果。
在代码探险时,要记住的一件事是童军格言:“准备万全”。预先花费一点精力来准备好您的工具总会在长远来看节省时间。过一段时间,所有工程师都会开发出一套稳定的工具来解决问题。当我进行代码探险时,我总是有一些手头的工具,并且每当我开始一个项目时,我都会确保用它们准备好基础。这些工具包括 Cscope 和 global,我稍后将更详细地讨论它们。
代码探险中非常常见的情况是尝试修复一段不熟悉的代码中的错误。调试是一项高度集中的任务:您有一个程序,它运行,但运行不正确。您必须找出它为什么这样做,在哪里这样做,然后修复它。程序出了什么问题通常是您唯一已知的量。在干草堆中找到针是您的工作,所以第一个问题必须是:“程序在哪里出错?”
您可以通过多种方式解决问题,其中许多方式如图 1 所示。您选择的方法取决于具体情况。如果程序是单个文件,您可能可以通过检查找到错误,但正如表 1 所证明的那样,任何真正有用的应用程序都比单个文件大得多。
让我们举一个理论上的例子。Jack 在 Whizzo 公司找到了一份工作,该公司生产 WhizzoWatcher,这是一个可以播放和解码多种娱乐内容的媒体播放器应用程序。在他上班的第一天(在签署了医疗保险、股票计划和 401K 计划之后),Jack 的老板给他发了两份错误报告,要求他处理。
Jack 刚刚被分配的两个错误如下
WhizzoWatcher 1.0 是一个典型的有机软件。最初被构思为“原型”,它让副总裁和投资者惊叹不已,并立即在编写它的工程师的反对下被仓促投入生产。它几乎没有或根本没有设计文档,而且现有的文档通常不准确且已过时。关于该程序的唯一真正的资料来源是代码本身。由于这是一个原型,一些开源软件被集成到更大的整体中,并期望在“真实系统”获得资金后被替换。整个系统现在由大约 500 个文件组成,分布在 15 个目录中,其中一些是在内部编写的,一些是集成的。
错误 1。 这是 Jack 最容易处理的错误;程序在启动时崩溃。他可以在调试器中运行它,并且会在下次崩溃时找到有问题的行。就代码探险而言,他一开始不必查看太多代码,尽管大致熟悉代码库将在长远来看对他有所帮助。
不幸的是,Jack 发现虽然立即崩溃的原因很明显,但导致崩溃的原因却不明显。C 代码中崩溃的常见原因是解引用空指针。在调试器的这一点上,Jack 没有程序之前的状态,只有崩溃时的状态,这实际上是非常少量的数据。一种常见的技术是在单步执行堆栈跟踪时目视检查代码,看看是否有调用者踩到了指针。
一个可以向后单步执行的调试器,即使只执行少量指令,在这种情况下也是一个福音。在进入函数时,调试器将为函数的所有局部变量和应用程序的全局变量创建足够的存储空间,以便可以在整个函数中逐语句地记录它们。当调试器停止(或程序崩溃)时,可以向后单步执行到函数的开头,以找出哪个语句导致了错误。目前,Jack 只能阅读代码,并希望偶然发现错误的真正原因。
错误 2。 攻击错误 2,代码不会崩溃但只会产生不正确的结果,这更加困难,因为没有一种简单的方法可以让调试器告诉您何时停止程序并检查它。如果 Jack 的调试器支持条件断点和监视点,那么这些就是他的下一道防线。监视点或条件断点会告诉调试器在变量发生意外情况时停止,并允许 Jack 在最接近问题发生的位置检查代码。
一旦 Jack 找到了问题,就该修复它了。关键是在不破坏系统中其他任何东西的情况下修复错误。彻底的测试是解决此问题的一种方法,但如果他能更多地了解它在系统中的影响,他会更放心地进行修复。Jack 的下一个问题应该是:“哪些例程调用了我想要修复的例程?”
尝试使用调试器回答这个问题是行不通的,因为 Jack 无法从调试器中得知所有将调用有问题的例程的源。这就是微妙的静态方法取得成果的地方。Cscope 工具从一段代码中构建一个数据库,并允许他执行以下操作
您会注意到第 4 项“查找调用函数的函数”回答了他的问题。如果他即将修复的例程修改了非局部变量或结构,那么他接下来必须回答的问题是:“我的函数与哪些函数共享数据?”这在“正确设计的程序”(即从头开始编写的程序)中永远不会出现。Jack 永远不会使用大量的全局变量来控制他的程序,因为他知道维护它将是一场噩梦。对吗?
当然,这是代码探险,所以 Jack 已经过了那个阶段。再次使用 Cscope 等微妙工具很有诱惑力,但这最终证明蛮力是他最好的希望。生成程序中所有全局引用的列表(文件名和行号)对于编译器来说当然是可能的,但它们都没有这样做。虽然此选项在创建新代码时几乎不会使用,但它会使调试旧代码的过程容易得多。Jack 将不得不结合使用 find 和 grep 来找出所有这些变量在程序中的位置。
代码探险不仅仅是您在调试时做的事情;它是您如何执行良好的代码审查、逆向工程设计文档以及进行安全审计。
在许多人使用计算机进行金融和个人交易的时代,审计代码中的安全漏洞已经(或应该)变得司空见惯。为了弥补这些安全漏洞,您必须了解常见的攻击是什么,以及代码的哪些部分容易受到攻击。虽然攻击几乎每天都在 Bugtraq 邮件列表 (http://www.securityfocus.com) 上更新,但我们关注的是找到它们。
考虑以下示例。Jill 在一家大型银行找到了一份工作,该银行通过互联网为大多数客户提供电子服务。上班的第一天,她的老板告诉她,银行正在对其整个系统进行安全审计,该系统在几种不同类型的硬件和操作系统上实现。一组 Unix 服务器处理传入的 Web 流量,然后向实际管理真实资金的大型机后端发出请求。
当程序以一种使任何有权访问机器的人都可以访问的方式存储用户的私有数据时,就会出现一个可能的安全漏洞。一个例子是将密码作为纯文本存储在文件或注册表项中。如果 Jill 编写了该软件,或者可以联系到编写该软件的人,她可以通过询问快速找出程序存储密码的位置。不幸的是,当银行六个月前将其总部迁走时,编写登录脚本的人被解雇了。Jill 将不得不在没有作者帮助的情况下找出这是如何完成的。
与调试情况不同,很少有工具可以告诉 Jill 像“程序在哪里存储数据 X?”这样具体的信息。根据程序的结构,这种情况可能发生在任何地方——而且经常发生。Jill 可以使用蛮力或微妙的方法来解决这个问题。要做到前者,她会在代码中添加调试语句(即 printfs 或语言等效语句),以找出程序存储密码的位置。她可能有几个猜测可以缩小范围,从整个程序缩小到几个或十几个位置,但这仍然是一项艰巨的工作。
一种更微妙的方法是在调试器中运行程序,尝试在用户输入密码后立即停止程序,然后跟踪执行,直到程序执行存储操作。
攻击程序的典型方法是给它错误的输入。为了找到这些漏洞,Jill 必须问一个问题:“程序从不可信的来源读取数据的位置在哪里?”大多数人会立即想到任何处理网络数据的代码都容易受到攻击,他们在某种程度上是对的。随着网络文件系统的出现,代码正在执行 read() 语句这一事实并不意味着它正在从本地(即可信的)来源读取。
在我的早期示例中,Jack 的调试试图集中解决一个问题,而 Jill 的代码审计更像是“扇出”。她想尽可能多地查看代码,并了解它如何与其自身的模块(“数据是如何传递的?”)以及外部实体(“我们在哪里读取和写入数据?”)进行交互。这可能是一项比在干草堆中寻找针更艰巨的任务,可能更像是标记所有单独的稻草。在这种情况下,Jill 最重要的事情是找到最有可能导致问题的地方——即最常执行的地方。
有一个工具,虽然最初并非用于此目的,但可以帮助集中 Jill 的工作:代码分析器。例如,gprof 最初是编写出来告诉工程师程序中的哪些例程正在占用所有 CPU 时间,因此是优化的候选对象。程序在工作负载下运行(让用户敲击它或让它为来自网络的请求提供服务),然后分析输出。分析器将告诉 Jill 哪些例程被调用得最频繁。这些显然是首先要检查的例程。Jill 没有理由仔细研究代码的很大一部分,这些代码很少被调用,而最常调用的例程可能存在巨大的漏洞。
将错误参数传递给系统调用的例程是另一个常见的安全问题。这通常针对网络服务器进行利用,以努力获得对远程机器的控制。一些商业工具试图发现这些类型的问题。一种快速而肮脏的方法是使用系统调用跟踪器,例如 ktrace 或 truss,来记录执行了哪些系统调用以及它们的参数是什么。这可以让您很好地了解可能存在错误的地方。
代码探险是关于提出问题。挑战在于掌握代码的正确部分并找到答案,而无需查看每一行(这几乎是不可能的)。本文中我还没有提到一种工具,那就是您头脑中的工具。即使在创建您正在探险的代码时没有使用良好的工程实践,您也可以开始应用它们。
记录您发现的内容以及事物如何工作的日志将有助于您创建并记住您正在探索的软件如何工作的图片。绘制图片也很有帮助;这些图片应与您的笔记一起以电子方式存储。您可能在物理课上学到——然后很快忘记——的良好实验设计也很有帮助。仅仅敲击一段代码直到它可以工作并不是弄清楚它的有效方法。设置一个实验来查看代码为什么以某种方式运行可能需要大量的思考,但通常只需要很少的代码。
最后,我最喜欢的工具之一是“笨程序员技巧”。您找一位同事查看代码,然后您尝试向他或她解释代码的作用。十分之九的情况下,您的同事甚至不需要说任何话。很快您就会意识到发生了什么,拍拍自己的额头,说声谢谢,然后回去工作。通过系统地大声解释代码的过程,您已经找到了问题。
没有一种工具可以使理解大型代码库变得更容易,但我希望我已经向您展示了一些处理这项任务的方法。我怀疑您会自己找到更多方法。
GEORGE V. NEVILLE-NEIL 在嵌入式系统领域工作了八年,既是最终产品的集成商,也是现成的嵌入式操作系统的实施者。他的工作主要集中在嵌入式系统的网络方面,但他也对系统的更广泛方面进行了通用工作。Neville-Neil 开发了一种用于 VxWorks 的网络设备设备驱动程序模型,致力于 Berkeley TCP/IP 堆栈的多实例版本,并将开源网络代码移植到 VxWorks。他目前正在 Nominum 开发一种新的商业动态主机配置协议 (DHCP) 服务器。他还教授研讨会和课程。他与计算机相关的兴趣是网络、操作系统、嵌入式系统和软件组件化。
工具资源
Globalhttps://gnu.ac.cn/software/global/
这是我应用于每个源代码库的工具。Global 实际上是一对工具:gtags 和 htags。第一个 gtags 基于 C、C++、Java 或 Yacc 中的源代码树构建有趣的连接数据库。一旦数据库构建完成,您就可以使用您的编辑器(Emacs 和 vi 都支持)在源代码中跳转。想知道您调用的函数在哪里定义?只需跳转到它。第二个工具是 htags,它采用源代码和 gtags 生成的数据库,并在子目录中创建源代码的 HTML 可浏览版本。这意味着,即使您不使用 Emacs 或 vi,您也可以轻松地在源代码中跳转,找到有趣的连接。构建数据库相对快速,即使对于大型代码库也是如此,并且每次从源代码控制系统更新源代码时都应该这样做。
Cscope
http://cscope.sourceforge.net/
Cscope 最初于 1970 年代在 AT&T 贝尔实验室编写。它可以回答许多问题,例如:此符号在哪里?全局定义的东西在哪里?此函数调用的所有函数在哪里?调用此函数的所有函数在哪里?与 Global 一样,它首先从您的源代码构建数据库。然后,您可以使用命令行工具、Emacs 或为与系统配合使用而编写的少量 GUI 之一来获得您问题的答案。
gprof
https://gnu.ac.cn/manual/gprof-2.9.1/gprof.html
这是大多数开源 Unix 系统上的标准分析工具。它的输出是一个例程列表,按它们被调用的频率以及执行它们花费的程序 CPU 时间量排序。这在弄清楚从哪里开始查找程序中的安全漏洞时很有用。
ktrace
这是开源操作系统上的标准工具。名称代表“内核跟踪”。它将为您提供程序发出的所有系统调用的列表。输出包括给调用和从调用接收的参数和返回值。您可以通过在其下运行程序然后使用 kdump 转储输出来使用 ktrace。它在所有开源 Unix 操作系统上都可用。
truss
http://wwws.sun.com/software/solaris/programs/abi/
这仅在 Solaris 上可用。它是 ktrace 的 Solaris 版本——基本上是相同的工具,但参数不同。
code spelunking
http://www.codespelunking.org
本网站包含一个致力于代码探险的 TWiki 协作页面。
最初发表于 Queue vol. 1, no. 6—
在 数字图书馆 中评论本文
Sanjay Sha - 企业应用程序的可靠性
企业可靠性是一门学科,它确保应用程序以一致、可预测且经济高效的方式交付所需的业务功能,而不会损害可用性、性能和可维护性等核心方面。本文介绍了一组企业可以应用的核心原则和工程方法,以帮助他们驾驭复杂的企业可靠性环境并交付高度可靠且经济高效的应用程序。
Robert Guo - MongoDB 的 JavaScript Fuzzer
随着 MongoDB 随着时间的推移变得更加功能丰富和复杂,开发更复杂的方法来查找错误的需求也在增长。三年前,MongDB 将自制的 JavaScript fuzzer 添加到其工具包中,它现在是我们最多产的错误查找工具,在两个发布周期中负责检测到近 200 个错误。这些错误跨越了从分片到存储引擎的一系列 MongoDB 组件,症状从死锁到数据不一致不等。fuzzer 作为 CI(持续集成)系统的一部分运行,它经常捕获新提交代码中的错误。
Robert V. Binder, Bruno Legeard, Anne Kramer - 基于模型的测试:它处于什么位置?
您可能听说过 MBT(基于模型的测试),但像许多未使用 MBT 的软件工程专业人员一样,您可能对其他人使用这种测试设计方法的经验感到好奇。从 2014 年 6 月中旬到 2014 年 8 月初,我们进行了一项调查,以了解 MBT 用户如何看待其效率和有效性。2014 年 MBT 用户调查是 2012 年类似调查的后续调查,向所有评估或使用过任何 MBT 方法的人开放。它的 32 个问题包括在 2013 年高级自动化测试用户会议上分发的调查中的一些问题。一些问题侧重于 MBT 的效率和有效性,提供了管理者最感兴趣的数字。
Terry Coatta, Michael Donat, Jafar Husain - EA 的自动化 QA 测试:由事件驱动
对于数百万游戏爱好者来说,在 Electronic Arts 担任 QA(质量保证)测试员的职位似乎是一份梦想的工作。但从公司的角度来看,与 QA 相关的开销可能会显得非常可怕,尤其是在大型多人在线游戏时代。