关于Dyninst论文《An API for Runtime Code Patching》翻译

本文介绍了一个名为Dyninst的后编译程序操作工具,它提供了一个用于程序插桩的C++类库,允许在运行时测试和修改应用程序。该库支持与机器无关的二进制插桩,介绍了三个示例工具:函数调用计数器、输出重定向工具retee以及条件断点实现。
摘要由CSDN通过智能技术生成

1.介绍:

我们提出了一个名为Dyninst的后编译程序操作工具,它提供了一个用于程序插桩的C ++类库。 使用这个库,可以在执行过程中测试和修改应用程序。 该库的独特之处在于它允许编写与机器无关的二进制插桩程序。 我们描述了一个工具在使用这个库时所看到的界面。 我们还讨论了使用此接口构建的三个简单工具:一个用于计算函数被调用次数的实用程序,一个用于捕获已运行程序对文件的输出的程序,以及一个条件断点的实现。 对于条件断点示例,我们显示通过使用我们的接口与gdb相比,我们能够使用条件断点执行程序的速度提高了900倍。

本文介绍了一个应用程序接口(API),以允许将代码插入正在运行的程序中。 使用此API,程序可以附加到正在运行的程序,创建一个新的代码位并将其插入到程序中。 正在修改的程序能够继续执行,不需要重新编译,重新链接或甚至重新启动。 下次修改的程序执行已修改的代码块时,除了原始代码之外,还会执行新代码。 该API还允许更改子程序调用或从应用程序中删除它们。

运行时代码更改对于支持各种应用程序非常有用,包括调试,性能监视以及支持现有程序包之外的应用程序组合。根据用途,代码可以通过辅助操作来扩充现有程序,例如测量应用程序性能或添加其他打印语句,或者可以通过更改执行的子进程或操作应用程序来改变程序的语义数据结构。 第二种类型的更改对于性能指导或其他调试应用程序非常有用。 我们的API旨在支持这两种用途。

我们的方法不同于其他编译后插桩工具,如EEL [11]ATOM [15]Etch [14],允许代码在开始执行之前插入到二进制文件中。 通常情况下,直到运行时才能知道要插入的特定代码。如果用户不确定他们需要什么类型的仪器,他们只有两种选择。首先,他们可以包括所有可能需要的插桩。这种方法可以确保收集到正确的信息,但成本过高可能会扭曲或掩盖相关现象进行测量。 其次,他们只能插入绝对需要的最少量的仪器。但是,如果省略了仪器的关键位,则程序员被迫重新执行该程序以启用该代码。 对于短期运行的程序,重新执行不是问题。 但是,对于大型科学模拟等长时间运行的应用,这可能需要数小时甚至数天的延迟。 此外,某些应用程序(如数据库服务器)在达到稳定状态之前可能需要长时间的“预热”。通过使用运行时检测,这些应用程序只能在期望的时间间隔内进行检测。

我们试图提供一个独立于机器的接口,以允许创建使用运行时代码修补的工具和应用程序。传统上,后编译器检测工具提供了允许插入机器或汇编语言级代码的接口。 相反,我们的接口更类似于仪器的机器独立中间表示形式作为抽象语法树。这允许相同的检测代码在不同的平台上使用。例如,考虑使用工具代码来监视数据库系统的行为(即跟踪提交和中止操作)。检测代码将特定于特定的数据库系统,但由于检测工具是独立于机器的,因此它可以与安装数据库系统的任何机器体系结构一起工作。

我们试图提供一个独立于机器的界面,以允许创建使用运行时代码修补的工具和应用程序。 传统上,后编译器插桩工具提供了允许插入机器或汇编语言级代码的接口。相反,我们的接口更类似于插桩的机器独立中间表示形式作为抽象语法树。这允许相同的插桩代码在不同的平台上使用。 例如,考虑使用工具代码来监视数据库系统的行为(即跟踪提交和中止操作)。 检测代码将特定于特定的数据库系统,但由于检测工具是独立于机器的,因此它可以与安装数据库系统的任何机器体系结构一起工作。

该接口的一个关键特性是它可以插入和更改正在运行的程序中的仪表。 使这成为可能的底层工作是作为Paradyn并行性能工具项目[12]的一部分开发的动态插桩技术[8]

这个API的目标是保持界面小而易于理解。同时它需要有足够的表现力以适用于各种应用。我们这样做的方式是为程序提供一组抽象,并指定要插入应用程序的代码。为了生成更复杂的代码,可以将额外的(最初未调用的)子进程链接到应用程序中,并且可以通过此接口在运行时插入对这些子例程的调用。 这些例程既可以静态链接,也可以作为动态库的一部分在运行时加载。 虽然这个API可以被程序员直接使用,但它主要针对工具制造商。 因此,基于AST的代码生成界面便于工具制造商使用,但对于手工构造来说却有点笨拙。

本文的其余部分分为以下几部分。第2节介绍了我们提供的基本摘要。第3节介绍了API中的关键类。第4部分概述了API如何实施。第5节介绍了API的几个应用程序,并说明了使用它的优点。第6节介绍相关工作,最后第7节包含结论和未来工作。

2. 摘要:

API基于程序的抽象以及执行时的状态。 两个主要抽象是点和片段。 一个点是程序中可以插入插桩点的位置。代码片段是在某个点上插入程序中的一些可执行代码的表示。例如,如果我们希望记录一个过程被调用的次数,那么该过程将成为过程中的第一条指令,并且这些片断将被用于创建一个语句来增加一个计数器。片段可以包括条件,函数调用和循环。

API的设计使得单个工具进程可以将片段插入到在单个机器上执行的多个进程中。为了支持多个进程,API中包含两个额外的抽象,线程和图像线程是指执行的线程。根据编程模型,线程可以对应于普通进程或轻量级线程。图像是指磁盘上程序的静态表示。图像包含可以修改其代码的点。每个线程只与一个图像相关联。

API的整体结构及其实现如图1所示。有两个过程,我们称之为mutator和应用程序。 该图的左侧显示了包含对Dyninst API的调用的mutiator进程的代码。 它还包含实现运行时编译器的代码和用于操作应用程序进程的实用程序例程(显示在标有矩形的下方API)。 图的右半部分显示了应用程序过程,并在图的顶部显示了程序的原始代码。应用程序的底部两部分是插入到程序中的片段,以及支持Dyninst API的运行时库。 第4节给出了关于实现如何工作的更多细节。


API包含一个简单的类型系统来支持整数,字符串和浮点值。 此外,还提供了对数组和结构等聚合类型的支持。 该接口允许对目标应用程序中存在的用户定义类型进行修改。 无法使用该接口创建新类型,但使用API构建的特定工具可以创建新类型作为其加载到应用程序的运行时库的一部分。

操纵其他流程的API的固有部分是需要对应用程序过程中发生的感兴趣事件作出反应。 在应用程序进程中发生两种类型的事件,由插入的代码引起的事件以及由于正常执行应用程序(如进程终止)而发生的事件。 为了提供一种统一的方式来处理这些事件,我们定义了各种回调函数,以通知变异函数在应用程序中感兴趣的事件。 另外,还有一些功能可以查询事件是否发生。

3.接口

在本节中,我们将描述API的主要类,并解释它们之间的关系。接口有三个主要组件 首先,有些类用于处理执行中的代码。该组包括BPatch类和BPatch_thread类。其次,有用于访问原始程序及其数据结构的类。这些包括BPatch_imageBPatch_moduleBPatch_function类。第三,有些类可以构建新的代码片段并插入它们。这些包括BPatch_snippet类和BPatch_point类。

BPatch :这个类代表整个Dyninst API库。 一次只能有一个这个类的实例。该类用于执行功能并获取不特定于特定线程或图像的信息。它还用于定义要针对特定应用程序事件调用的回调函数。

BPatch_thread:在执行中操作(并创建)代码。这个类可以用来操纵线程。例如,线程可以通过使用类中的方法来启动,停止或终止。另外,线程类用于将检测代码插入到程序中。对于线程应用程序,接口表示单个执行线程。API的实现可确保即使将检测代码插入到多线程应用程序的特定线程中,该代码仅针对该线程执行。 对于非线程代码,线程抽象代表了一个传统的过程。

BPatch_image:是代表程序可执行文件的抽象。图像仅作为线程的一部分存在,因为磁盘上具有相同程序的两个进程在执行不同的动态库期间可以加载,因此具有用于检测的不同模块。

BPatch_module:代表一个程序模块,它是程序可执行映像的一部分。提供模块是因为它们通常是有意义的程序分解单元。 一般来说,模块是指原始程序中的单个源文件。但是,对于许多库(特别是动态加载的库),模块抽象被用来表示整个库。

BPatch_function:代表应用程序中的功能。一个函数对于检测通常是一个有用的抽象级别,所以有一些方法可以获取函数的入口和出口,并将它们用作检测点。此外,还有类的方法来确定函数调用的子进程以及函数中的循环和代码块。

BPatch_point:是应用程序代码中的位置,在该代码中库可以插入instrumentation.Points可以通过降级函数层次结构(即循环)或通过提供虚拟地址来象征性地描述(即函数的入口点) 该程序(即,具体说明或指示)。

Bpatch_typeBpatch_type定义类型系统的接口。类型可以是预定义的语言类型,也可以是应用程序中某处出现的用户定义类型。类型系统用于定位用于代码片段的变量,并为增变器程序或片段访问现有的应用程序变量提供一种方法。

BPatch_snippet是要插入程序的代码的抽象表示形式。片段通过创建片段的适当子类的新实例来定义。例如,要创建一个片段来调用一个函数,就会创建一个类BPatch_funcCallExpr的新实例。创建代码段不会导致代码被插入到应用程序中。相反,当请求在程序中的特定点处插入片段时,会生成代码。子片段可以由不同的片段共享(即,片段可以作为参数被传递以创建两个不同的片段),但是所生成的代码是否在两个片段之间被共享(或复制)是依赖于实现的。下一小节将详细介绍片段。

3.1代码片段

在本节中,我们将描述我们如何表示要生成的代码。类BPatch_snippet的实例集合以及表示要插入的不同类型的代码的特定子类表示由mutator添加到应用程序的语句。片段的集合形成直接的a循环图。通过调用相应的C ++构造函数并将以前创建的子片段传递给每个构造函数来定义代码。通过这种方式,AST从叶节点创建到根。我们现在简要描述每种类型的代码片段。

BPatch_variableExpr派生自片段类。它表示线程地址空间中的变量或内存区域。可以使用malloc成员函数从BPatch_thread或使用findVariable成员函数从BPatch_image获取BPatch_variableExprBPatch_variableExpr提供了两个不由其他类型片段提供的成员函数:readValuewriteValue。这些方法允许增变器程序读取或写入应用程序地址空间中的变量值。

BPatch_arithExpr用于我们代码定义中的大部分两个操作数语句。算术表达式涵盖了一大类操作,包括变量赋值,基本数学运算和数组引用。仅对预定义类型支持算术运算。对于指定重载运算符的C ++程序,API用户必须直接调用运算符函数除了使用表达式符号。除了标准的一元运算,如否定和指针解引用外,可用的二元运算符还有:

BPatch_boolExpr表达式片段定义了两个变量之间的一组比较操作。操作仅在基本类型(即整数)上定义。与BPatch_arithExpr一样,如果要使用C ++运算符重载函数,则必须手动调用它们。

BPatch_gotoExpr使用goto表达式在摘录内提供了一种简单的分支形式。 goto表达式允许一个片段分支回该片段的较早部分。 通过将它与条件语句相结合,它可以用来构造循环。 但是,对于复杂的循环,通常最好将代码编写为函数,并使用API修补该函数的调用。

4.实施

在本节中,我们提供了用于在运行时编译检测代码和补丁程序的高级描述。尽管本文主要关注高级抽象,但有关实现的一些信息对于理解运行时生成的代码的预期性能很有用。 有关实施的完整细节可在其他论文中找到[8,9]。尽管试图将新代码用于正在运行的程序中有许多棘手的细节,但API的一个关键特性是它从工具构建器中省略了这些细节。

mutator进程对应用程序进程的基本操作使用调试器使用的相同操作系统服务(例如,ptrace/ proc文件系统等)。这些服务提供了一种方法来控制流程执行,并读取和写入应用程序的地址空间。此外,包含实用程序函数和两个大型数组的动态链接库将加载到应用程序中进行检测。这两个数组都用于动态分配小区域的内存。其中一个数组用于检测变量,另一个用于保存检测代码1。这两个数组都作为堆进行管理,以便为运行时代码生成提供动态分配的存储。

为了生成代码,我们将代码片段翻译成更新器进程内存中的机器语言代码,然后将其复制到应用程序地址空间中的数组中。插入仪器的最困难的部分是仔细修改原始代码以分支到新生成的代码中。为此,我们使用称为蹦床的代码的简短部分。图2显示了蹦床的结构及其与仪表点的关系。蹦床提供了一种从我们希望将检测代码插入到我们新生成的代码的地方获得的方法。要做到这一点,我们用仪器点上的一个或多个指令替换为基础蹦床的开始。基础trampo-line代码然后分支到一个迷你蹦床。迷你蹦床保存适当的机器状态(如寄存器和条件代码),并包含单个代码片段的代码。在代码片段结尾处,我们放置代码来恢复机器状态并跳转回基本蹦床。基地蹦床然后执行从原始代码中移除的指令。如果在该点执行后插入片段(即,在函数调用返回之后),我们也可以在这里插入一个迷你蹦床。

可以在一个点插入多个片段,并将它们链接在一起,以便一个片段的末尾分支到下一个片段的开头,最后一个片段分支回蹦床。

5. 使用Dyninst API

为了深入了解我们的API如何用于构建工具,我们为三个应用程序提供了描述(以及少量示例代码)。第一个应用程序显示一个简单的代码片段,用于在调用选定的过程时增加一个变量。第二个应用程序retee演示了一个可以使用API构建的简单独立实用程序。第三个例子是快速条件断点,显示了如何将API用作更大工具的一部分,如正确性调试器。

使用API直接创建程序是可能的,但有点乏味。 我们预计API的大多数用户将成为将为指定仪器创建更高级别语言的工具构建者。 例如,MDL语言[9]提供了一种适用于特定任务的简单度量脚本语言,如定义性能指标。 我们预计其他“小语言”将用于特定用途的API

5.1过程调用计数

为了说明API的想法,我们首先提供一个简短的例子,它将插装到目标程序中以计算过程被调用的次数。 虽然这是一个简单的例子,但它可以说明API的关键特性。

这个工具的示例代码如图3所示。一个mutator程序必须做的第一件事是创建一个称为BPatch的顶级类的单个实例。 该对象用于访问库中全局的函数和信息。 程序的第一行是这样做的。

其次,增变器标识要修改的应用程序进程。 如果进程已在执行中,则可以通过指定应用程序的可执行文件名和进程ID作为参数来创建线程对象的实例。 或者,如果要创建新进程,可以调用createProcess例程(如第2行所示)。

一旦创建了应用程序线程,mutator将定义要插入的代码片段以及它们应该插入的位置。 在我们的例子中,第4行和第5行显示调用来查找目标过程入口点的句柄。返回值实际上是一个点列表,因为程序可能会被克隆到程序的不同位置,或者可能会被重载。第6-7行在应用程序的地址空间中创建一个新的整数变量。创建新变量的第一步是查找类型。一旦找到该类型的句柄,就会使用malloc方法创建该类型的实例。第8行和第9行显示了构造一个整数变量的简单增量的过程。这需要构造一个整型常量表达式,一个加法表达式,然后是一个赋值语句。最后,第10行显示了增量语句插入程序中所需的点。

5.2 Retee Example

在本节中,我们将展示一个几乎完整的程序来演示如何使用API。这个例子是一个名为“retee”的程序。我们将应用程序称为“retee”,因为它像Unix命令“tee”一样工作,将输出传递到自己的标准,同时将其保存在文件中,但与“tee”命令不同,它可以在应用程序开始执行后启动。 示例程序的动机是正在运行的程序开始向屏幕输出大量输出,并且用户希望将该输出保存在文件中,而无需重新运行该程序。 Retee有三个参数:可执行程序的路径名,同一程序正在运行的实例的进程ID以及文件名。 它将代码添加到正在运行的程序中,该程序将所有输出复制到指定文件的程序写入其标准输出文件描述符。

该工具首先使用API的一次性代码功能强制应用程序打开要用于记录的文件。一次性代码功能允许mutator调用一次片段,然后控制权返回到mutator程序。这对于各种类型的初始化代码很有用。

在应用程序中打开文件的代码如图4所示。第一行查找函数“open”的句柄。第2-8行构建openCall的参数列表。第一个参数(第3-4行)是一个字符串,通过命令行参数输入到“retee”应用程序。当包含字符串的片段被插入到应用程序中时,该字符串也将从mutator到应用程序地址空间。第二个和第三个参数(第5-8行)每个都包含一个带有文件模式和保护位的整数。第9行包含一个声明,用于从函数名称和参数列表构造整个函数调用。第10-11行在应用程序的地址空间中创建了一个类型为integer的新变量。第12行构造一条语句,将openCall的返回值分配给第11行中创建的新变量。最后,第13行使用一次性代码接口来编译并执行构建的代码段。

retee程序的第二部分将代码插入C运行时库中写入函数的入口点,然后检查写入系统调用是否适用于文件描述符编号1(即标准输出)。 如果调用符合此测试,则会额外调用写入函数来重复写入语句并将输出发送到我们先前打开的文件。 请注意,虽然我们的检测代码片段是递归调用的,但检查文件描述符等于1会在第二次调用时失败,因此无限递归没有问题。

生成并插入此片段的代码如图5所示。第1行在应用程序中查找写入系统调用。 第2-7行为使用创建的文件描述符(来自图4)的写入系统调用生成一个函数调用的参数列表。第3行和第4行使用参数表达式来访问原始调用的缓冲区和长度参数写。第8行的声明创建函数调用本身。第9-11行首先创建一个关系表达式来检查文件描述符是否为零,然后仅当条件语句为true时才生成if语句来调用写入系统调用。最后,第12行将构建的代码片段插入到应用程序中。

5.3 Conditional Breakpoints

作为Dyninst API的演示,我们编写了一个程序,像调试器一样控制应用程序进程,并允许用户在任何可由Dyninst API检测的位置设置条件断点。 用户可以在运行应用程序的过程中添加或删除任意数量的断点。在大多数调试器中,条件断点是非常昂贵的,因为它们通常使用驻留在调试器中的代码而不是被调试的应用程序来实现。这意味着调试器必须设置无条件断点并等待进程停止。当它发生时,调试器会进行系统调用以查看应用程序的地址空间并检查条件,然后在条件不满足时自动继续应用程序。

我们的演示程序采用了不同的方法。 它将断点的条件编译到Dyninst API代码片段(BPatch_snippet)中,该代码片段检查条件并在条件满足时生成信号。然后使用BPatch_threadinsertSnippet成员函数将此片段插入到需要断点的位置的应用程序中。

例如,在程序执行期间多次调用特定代码段时,条件断点是有用的,但只有在某些情况下才知道或怀疑其行为不正确。 一些示例可能包括何时使用某些参数调用某个函数,或者当程序在处理其输入时达到某个特定点时。

演示程序是在一个下午编写的,包含371C ++代码,外加78lex规范和149yacc,以解析用户提供的条件表达式。整个程序总计少于600行代码。

因为条件检查是通过修补到应用程序中的代码来完成的,所以应用程序可以在调试器无需干预的情况下运行,直到达到断点,从而节省潜在的许多上下文切换和系统调用的开销。 为了量化如何在调试器下运行应用程序时降低性能,我们在我们的工具和GNU调试器下运行了两个应用程序。对于每种情况,我们在条件断点插入不同位置时测量性能。这两个程序取自SPEC '95基准测试套件[1]。第一个是compress95,是Unix压缩程序。 另一个,李,是一个Lisp翻译。我们在运行Solaris 2.5167 Mhz UltraSparc-1上执行测试。


这些测试的结果平均超过20次运行,如图6所示。第一列显示应用程序和插入断点的函数。第二列“操作次数”是在运行过程中达到断点的次数和条件评估的次数,“ops / sec”列显示了在每次运行时每秒评估断点条件的次数使用我们的工具运行。第四列显示使用我们的工具使断点状态变为真所需的挂钟时间,“gdb time”表示gdb版本4.17下的挂钟时间。

compress95中,我们在函数“output”中插入了一个断点,该函数输出一个代码(代表一串字节的标记)。当使用16位代码(程序使用的代码的大小随着程序处理输入文件而增加)时,第一次调用“输出”函数时,断点停止执行程序。像这样的断点可能是有用的,例如,如果程序仅在代码达到特定大小时才显示错误。我们在“压缩”函数开始时开始计时。 “输出”函数经常被调用,结果gdb参与评估断点的开销非常高,这可以从应用程序花费将近930倍的时间来达到最终断点时间。

li中,我们在三个函数之一(xlmatchcomparebinary)中插入了一个断点。在每种情况下,当用某个参数调用函数时,断点停止程序。结果显示我们的程序相对于gdb的优势是如何降低的,因为必须评估断点条件的频率降低。 通过我们尝试的最不频繁调用的函数(二进制)的断点,gdb下的运行时间仍然比断点下的运行时间长4.5%。平均所有实验的结果,每次必须评估条件时,gdb似乎比Dyninst API程序延迟了大约2毫秒的时间。



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值