C/C++调试---汇编

汇编

与调试构建相比,优化后的程序更难调试。
调试程序的主要挑战可以概括为以下几个任务:
(1)在所需的源代码行号处设置断点。
(2)给定一条指令地址,例如导致程序崩溃的指令,找到对应的源代码行。
(3)打印局部变量和函数参数。
为了解决这些基本任务,通常需要了解编译器生成的汇编代码,这些代码会揭示变量存储或更改的位置和时间。

寄存器

寄存器是高速芯片上的内存,也是执行上下文的主要存储单元,大多数机器指令使用寄存器作为操作数。要理解汇编代码,就需要知道这些寄存器的使用方式,了解这些寄存器及它们在各种指令中的用途对于掌握汇编代码至关重要。

x86_64架构包括16个通用寄存器(rax,rbx,rcx,rdx,rbp,rsi,rdi,rsp,r8,r9,r10,r11,r12,r13,r14,r15)、标志寄存器RFLAGS和指令指针寄存器rip。它们都是64位宽,低字节也可以单独访问。

(1)寄存器rax是累加器。它在几个算术指令中被使用,如加法、减法、乘法和除法指令。许多类型的字符串指令也隐式地使用rax,例如加载字符串、存储字符串和扫描字符串时使用rax作为数据存储,并将rdi或rsi作为目标和源字符串的偏移量。同步指令和比较交换指令也使用rax作为操作数之一。

(2)寄存器rcx是计数器。移位和旋转指令可以使用rcx的低字节来指定操作数应被移位或旋转的位数。有几个条件跳转指令使用它来代替RFLAGS寄存器,例如JCXZ指令在rcx的值达到0时将控制权交给目标指令。

(3)寄存器rsi和rdi分别是源索引和目标索引。大多数字符串操作将rsi作为源操作数,将rdi作为目标操作数。对于重复的字符串操作,随着字符串元素的移动,rsi和rdi都会相应地进行递增或递减。寄存器rcx被用作字符串长度计数器,每次移动都会递减。

(4)寄存器rsp是指向线程栈顶的栈指针,而寄存器rbp是指向当前栈帧开始的基指针。当值被压入栈时,rsp隐式地递减,当它们被弹出栈时,它则递增。ENTER和LEAVE指令使用rbp作为栈帧基指针。

指令集

大多数x86_64指令采用下面显示的两种形式之一:
在这里插入图片描述
源操作数可以是寄存器、内存引用或立即数。目标操作数可以是寄存器或内存引用。但是对于x86系列芯片,源操作数和目标操作数不能同时为内存引用。

移动指令

在这里插入图片描述
其中,push和pop指令专用于访问栈内存。push指令将源操作数的值保存在栈寄存器%rsp指向的地址处。pop指令做相反的事情,从栈顶移除一个值到目标操作数。两条指令都隐含地更改栈指针。由于栈朝着较低地址空间的方向增长,因此push会减少%rsp寄存器的值,而pop会根据操作数的大小增加它的值。

算数运算指令

在这里插入图片描述

分支指令

在这里插入图片描述

转移指令

在这里插入图片描述
其中,ret指令从栈中弹出一个地址并将其加载到指令指针%rip中。它还可以将一个数字作为唯一操作数,表示在返回调用者函数之前从栈中删除多少字节。
leave指令恢复CPU状态,等同于以下两个指令:

mov rbp, rsp
pop rbp

上述两个指令表示,首先将栈寄存器rsp恢复为当前栈帧寄存器rbp,然后弹出前一个栈帧寄存器值rbp。注意,在弹出帧寄存器时,它还会调整栈寄存器rsp。净结果是栈帧向后移动一个层次,回到当前函数的调用者。由于这个指令更小(只有1字节)且更快,因此编译器可能会在发布构建中选择它。

其他指令

在这里插入图片描述

程序汇编的结构

阅读汇编代码时,会发现所有函数的开头和结尾都非常相似,它们分别被称为函数序言(Prologue)和结语(Epilog)。函数序言给被调用函数设置一个栈帧,而结语做相反的事情,释放栈帧并返回到前一个调用函数的栈帧。典型序言的片段如下:
在这里插入图片描述
第一条指令将栈帧指针压入栈。第二条指令将它设置为一个新值,该值自然是前一个栈帧的结尾。第三条指令通过从栈寄存器中减去56字节向下扩展栈,也就是为调用函数分配了56字节的栈空间,以便让函数的局部变量、参数、临时变量等使用。如下图:
在这里插入图片描述
注意,调用指令会将返回地址压入栈。
函数结语如下:
在这里插入图片描述
前面提到leave指令等同于两条指令——mov rbp、rsp和pop rbp,它与序言正好相反,它首先恢复栈指针,然后将前一个帧寄存器rbp的值弹出栈。
注意,pop指令会将栈寄存器rsp隐式地调整8字节(32位模式下为4字节),这会将rbp和rsp完全恢复为先前栈帧的值。
ret指令弹出下一条要执行的指令的地址,并将它设置到程序计数器寄存器rip。这为线程在完成此函数调用后继续运行做好准备。

用户代码生成的指令很大一部分是对数据的读取或修改。根据变量所在的位置,有多种访问变量的方法,这体现在使用的内存寻址方式上。下面是一个简单的例子,在源代码以粗体字标示,在每个源代码行之后列出了相应的汇编代码。
在这里插入图片描述
在这个简短的函数Bar中有一个全局变量g_count、一个参数index,以及两个局部变量sum和result。
全局变量具有相对于模块加载基址的固定地址,因此,通过指令相对寻址g_count(%rip)访问它。该指令通过将当前指令的地址与偏移量相加来计算变量的地址,偏移量是在编译时确定的常数。
参数index通过寄存器edi传递,并存储在栈-4(%rbp)上,距离函数的栈帧4字节。
局部变量sum和result分别在栈帧的8字节和16字节处分配,即-8(%rbp)和-16(%rbp)。
如示例所示,栈上的变量通常通过栈帧寄存器加负偏移量访问;堆数据必须通过指向内存地址的指针或引用进行访问,在这里局部变量result指向堆上的一个新创建的整数对象。通过解引用操作(%rdx)将对象设置为计算出的值,其中寄存器rdx保存堆地址。

函数调用习惯

应用程序二进制接口(Application Binary Interface,简称ABI)是编译器用来生成兼容二进制文件的协议。
函数调用约定,这个约定规定了函数调用在调用者和被调用者之间的交互方式,如参数的传递方式和寄存器的保护,以及栈帧的布局等。此外,无论是在寄存器中还是在栈中,理解局部变量和参数的位置对于理解程序汇编都十分关键。
每当调用一个函数时,编译器都会从栈顶部分配一个新的栈帧,即一片内存。我们在函数序言中已经看到了这一点。部分或全部函数的参数和局部变量都存储在栈帧上。函数的返回地址(也就是被调用的函数返回时要执行的下一条指令)以及前一个栈帧寄存器也保存在栈帧上。
在x86_64架构中,寄存器rbp、rbx以及r12~r15被归为调用函数所有,也就是说,编译器需要保证在函数调用的过程中,这些寄存器的值不被改变。如果被调用的函数需要使用这些寄存器,那么它必须保存它们的当前值,并在函数执行完毕后恢复这些值,使得当控制权返回给调用者时,这些寄存器的值与函数调用之前的保持一致。其余的寄存器则为被调用函数所有,因此,如果调用者需要在函数调用后使用这些寄存器以前的值,那么它在调用之前有责任保存这些寄存器的值,并在调用后恢复它们。
函数参数的传递方式可以是通过寄存器,也可以是通过栈,这取决于参数的类型以及寄存器的可用性。ABI为不同类型的参数定义了一组类,规定了相应的传递方式。

  • INTEGER类包括适合通用寄存器的所有整型。
  • SSE类包括适合SSE寄存器的类型。
  • X87类包括将通过x87 FPU返回的类型。
  • MEMORY类包括将通过栈内存传递和返回的类型。
  • NO_CLASS作为分类算法的初始值,用于填充、空结构和联合(Union)。
    参数的大小向上取整为8字节,因此,栈始终以8字节对齐。

给每种数据类型都分配一个类。例如,基本类型按如下方式分类:

  • 有符号或无符号bool、char、short、int、long、long long和指针属于INTEGER类。
  • float和double类型属于SSE类。
  • long double类型属于X87类。

聚合与复合(结构和数组)和联合类型按以下规则分类:

  • 如果对象的大小超过两个8字节,或者是C++的非POD(Plain Old Data,平凡数据)结构或联合(因为它们需要一个定义良好的地址,这样调用函数和被调用函数就可以在同一地址上构造和析构对象。这对于从被调用函数返回的非POD结构也是一样),或者如果它包含未对齐的字段,那么它会被归类为MEMORY类。
  • 如果聚合的大小超过单个8字节,那么每个都会被单独分类。每个8字节都初始化为NO_CLASS类。
  • 如果一个C++对象有非平凡的复制构造函数或非平凡的析构函数,那么它将通过不可见的引用来传递(在参数列表中,该对象被一个具有INTEGER类的指针替代)。
  • 对于聚合类型,递归地对每个字段进行分类。每次都比较两个字段并按以下规则将所有字段合并在一起。
    • 如果两个类相等,则结果为该类。
    • 如果一个是NO_CLASS,则结果是另一个类。
    • 如果一个是MEMORY,则结果是MEMORY类。
    • 如果一个是INTEGER,则结果是INTEGER类。
    • 如果一个是X87,则结果是MEMORY类。
    • 否则,结果是SSE类。

6个通用寄存器和8个SSE寄存器可用于参数传递:%rdi、%rsi、%rdx、%rcx、%r8、%r9、%xmm0~%xmm7。在函数参数按上述规则分类之后,它们将按从左到右的顺序分配给寄存器,如下所示:

  1. 如果类是MEMORY,则参数在栈上传递。
  2. 如果类是INTEGER,则按顺序分配%rdi、%rsi、%rdx、%rcx、%r8、%r9给下一个可用寄存器。
  3. 如果类是SSE,则按顺序分配%xmm0~%xmm7给下一个可用寄存器。
  4. 如果类是X87,则参数在内存中传递。
    当上述所有寄存器都已经被使用完毕时,无论参数的类型如何,额外的参数都将会以反向的顺序被推入栈内存中。也就是说,参数将会按从右向左的顺序进行传递。这种反向传递参数的方式使得处理可变数量的参数变得更加简单,因为无论在运行时实际传递了多少个参数,第一个参数的位置都是静态的、固定的。这样一来,不管参数的数量如何变化,我们都可以轻松找到第一个参数的位置。

被调用函数的返回值(如果有的话)与参数传递类似,只有一项数据需要返回,它遵循类似的规则:

  • 如果返回类是MEMORY,则调用者应为返回值提供空间,并将该存储的地址作为第一个隐藏参数传递给寄存器%rdi。返回时,寄存器%rax将包含通过寄存器%rdi传入的地址。
  • 如果返回类是INTEGER(这是最常见的情况),则使用寄存器%rax。如果它已经被占用,则使用寄存器%rdx。
  • 如果返回类是SSE,则使用寄存器%xmm0。如果它已经被占用,则使用寄存器%xmm1。
  • 如果返回类是X87,则返回值将作为80位X87数字传递到X87栈中的%st0。
    在这里插入图片描述
    实例
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    根据前面介绍的ABI,参数应该通过寄存器和栈传递,示例中各参数传递情况如下:
    在这里插入图片描述
    可以看见,虽然参数i_long1是一个适合放入通用寄存器的长整数,但是所有的6个通用寄存器都已经被前面的参数占用了,因此参数i_long1只能通过栈来传递。
    另外,关于参数i_nonpod,这里有一点需要特别指出。由于它不是POD结构(也就是说,它是一个包含了虚函数声明的类),因此不能像参数i_pod那样通过寄存器来传递。在这种情况下,调用函数会在调用者的栈上创建一个NONE_POD_STRUCT类型的对象,并通过一个整数寄存器来传递这个对象的地址,在本例中,这个寄存器是%rcx。

下图详细显示了从函数main调用函数Sum时是如何使用栈的。变量和函数名称以粗体字标示出来。
注意,函数main栈上创建了两个匿名临时对象,它们是本地对象a_pod和a_nonpod的副本。它们存在的目的是作为参数传递给函数Sum。为了方便讨论,我们用名称tmp_pod和tmp_nonpod来区分它们。当函数Sum被调用时,对象tmp_pod再次被复制到寄存器%rdx中并传递给函数。因为tmp_nonpod是非POD结构,所以不能通过寄存器传递,而是把它的地址(其有效地址是%rbp-128)通过寄存器%rcx传递给函数。
在这里插入图片描述灰色区域表示为满足对齐要求所增加的填充。例如,结构POD_STRUCT的数据成员“s”是一个短整数,2字节长。它后面是数据成员“a”,是一个整数,需要在4字节边界上对齐。因此在数据成员“s”和数据成员“a”之间有2字节的填充。

函数main的栈帧长176字节。栈指针%rsp指向帧底部,栈帧指针%rbp指向帧顶部,很明显%rsp=%rbp-176。函数Sum的栈大小为64字节。然而,由于它是一个末端函数,意味着它不调用任何其他函数,因此编译器根本不会费心调整栈指针%rsp,它与函数帧指针%rbp保持相同的值。

源码与汇编结合

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
一旦理解了局部变量、参数和临时对象在栈内存中的布局,以及寄存器的使用方式,阅读汇编代码就会变得十分简单。需要注意的是,如果C++对象参数按值传递,那么它会被赋予一个临时对象,并且如果它们是非平凡的,那么这些对象会通过类的复制构造函数和析构函数进行初始化和清理,即使是通过寄存器传递的对象,也是这样的。当然,我们通常通过引用而不是通过值来传递对象。这种情况下,参数是指针类型,如果有可用的寄存器,就可以通过寄存器来传递这个指针。

参考:高效C/C++调试

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

麦兜c

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值