一月 7

C语言右移操作在汇编层面的相关解释

在 CSDN 看到帖子回复如下:

x=y>>2;
004122A8  mov         eax,dword ptr [y] 
004122AB  sar         eax,2 ‘算术移位 
004122AE  mov         dword ptr [x],eax 
x=((unsigned int)y)>>2;
0041228D  mov         eax,dword ptr [y] 
00412290  shr         eax,2 ‘逻辑移位
00412293  mov         dword ptr [x],eax 
x=(0x0|y)>>2;
00412296  mov         eax,dword ptr [y] 
00412299  sar         eax,2 ‘算术移位
0041229C  mov         dword ptr [x],eax 
x=(0xFFFFFFFF&y)>>2;
0041229F  mov         eax,dword ptr [y] 
004122A2  shr         eax,2 ‘逻辑移位
004122A5  mov         dword ptr [x],eax 

故有人问,为何 x=(0x0|y)>>2; 是算术移位,而 x=(0xFFFFFFFF&y)>>2; 是逻辑移位。

实际上,不难看出当 0x0 | y 时,将产生与 y 相同的表达式值,所以 (0x0 | y) >> 2 和 y >> 2 一样都是算术移位(它们都是 int 数据类型,有符号)

而当 0xFFFFFFFF & y 时,无论 y 的值为多少,0xFFFFFFFF 这个字面值(只要 int 数据类型的字面值超过 0x7FFFFFFF)会被编译器当做无符号数处理,所以 & 运算将进行无符号数的按位与运算,也会产生一个无符号数的结果,故最终进行无符号的移位(逻辑移位)

 

对于,0xFFFFFFFF 这样的字面值,可以用以下方式验证

它将产生这样的编译错误:

这说明了,编译器的确将 0xFFFFFFFF 这个字面值当成了无符号类型。

以上。

九月 13

for 循环的反汇编浅析

for 循环

    for 循环是使用频度最高的循环结构,我们通过 C 语言反汇编实例,来分析 for 循环结构在计算机底层的原理和构造。首先,我们编写一个简单的 for 循环:

image

    为了方便观察,我们用十六进制来表示循环变量,编译后,我们用 OllyDBG 载入,我们可以看到对应的反汇编代码。

 

禁用优化的情景

    这里的“push ecx”相当于“sub esp, 4”,为 i 变量分配空间。接着,i 被赋值成 0,并且跳转到 004095C6 处,刚好此处是一条 cmp 指令,与 baseline 条件值(此处是 0x10)进行比较,如果大于等于则跳出循环,否则,程序会接着往下执行,执行完 printf,就会跳转到 004095BD 处,这时将进行循环的变量的自增。如此往复直到 JGE 跳转成立。

image

 

最小化大小的情景

    当编译器使用最小化大小(O1)编译优化选项时,for 循环将产生体积更小更精简的机器代码。如下图所示:

image

我们会发现有许多与前者不相同的地方,比如循环变量在此情景中变成了 ESI 寄存器,JGE 变成了 JL。

 

最大化速度的情景

    当编译器使用最大化速度(O2)编译优化选项时,for 循环将产生更加高效的机器代码。如下图所示:

image

存在的诸多细微的差异,还需自己深入慢慢体会。

九月 12

switch 语句的反汇编浅析

switch 的简单情景(case 不超过 3 项)

    首先,我们分析一下 switch 语句的一种简单情景,我们可以用 C 写出如下如下代码。

image

    编译后用 OllyDBG 载入,它将显示出如下的反汇编代码。

image

    首先,我们可以看到 ESP 减少了 8,除了定义变量 a 外,编译器还分配了一个临时变量(这里暂且叫它 t)用于比较。t 被赋值成 a 的值,然后与立即数 0x10,0x20,0x30 依次比较。如果有一项相等,那么就跳转到 case 里面,如果都不相等,就会无条件跳转到 default 里面。执行完 case 或 default 里面的代码之后,就会无条件跳转到 end 的位置。

 

switch 跳转表情景(case 超过 3 项)

    上面是 switch 中比较简单的情景,但是当 case 项超过 3 项时,情景将发生很大的变化。我们可以先编写如下 C 语言代码,这里 switch 的 case 项已经超过 3 项。

image

    编译后,用 OllyDBG 载入,可以得到如下的反汇编代码。与之前的简单情景类似,除了定义变量 a 外,编译器还分配了一个临时变量(这里暂且叫它 t)用于比较,t 被赋值成,接着我们可以观察到 t 减了 0x10,然后与 0x8A 做比较。实际上,我们不难看出 t 先减去了 case 项中的最小值,然后与 case 项中的最大值和最小值的差进行比较。这样做的目的是首先排除极端情况(如果 t 比最大值要大,或者比最小值小,那么肯定是 default),接下来的 JA 指令就能排除这类极端情况,提高程序执行效率。

    说到这里,有些人可能不能理解为啥要先减去最小值,然后再与最大值和最小值的差进行比较。其实道理很简单,假设变量的值是 t,最小值是 min,最大值是 max,我们很容易建立一个比较关系,t – min 与 max – min 之间的比较。这里很容易化简成 t 与 max 的比较,如果 t 比 max 大,说明这是极端情况应该去 default。还有一种可能就是,t – min 的时候,t 比 min 小,这样寄存器就会溢出,t 会变得很大很大且大过 max,这也会变成极端情况。换言之,这样的一套操作下来,程序能保证 t 在 min 和 max 之间是能被 case 接受的,否则就是极端情况而进入 default。

image

    我们往后看,假设变量的值不是极端情况,那么程序就会把 t 的值赋值给 EDX 寄存器,注意,这里的 t 已经减去了 case 项的最小值,换言之,如果把 case 项中的最小值看做起始点 0,最大值看做最大值与最小值的差,那么 t 就是介于最小值与最大值之间的某个点。

    接下来,eax 被赋值成某个基址 + EDX 的值。我们跟进这个基址可以看到以下的内容:

image

我们仔细分析一下 EDX 的值(也是 t 的值)与基址起始的字节数据的内在联系。

  • 当 EDX == 0x0 时,取出数据 0x0 给 EAX。
  • 当 EDX == 0x22 时,取出数据 0x1 给 EAX。
  • 当 EDX == 0x34 时,取出数据 0x2 给 EAX。
  • 当 EDX == 0x8A 时,取出数据 0x3 给 EAX。
  • 当 EDX >= 0 且 EDX <= 0x8A 时,除去以上情况,将取出数据 0x4 给 EAX。

仔细观察可以发现,这里的内存数据从基址偏移 0x0 ~ 0x8A 的每个字节都是一个指示值,在此例中 EAX 可以被赋值成五个值 0,1,2,3,4,5。EDX 实际上也就是 a 减去 case 项最小值的结果,所以,最终我们能建立一个映射关系:

  • case 0x10 –> EAX = 0x0
  • case 0x32 –> EAX = 0x1
  • case 0x44 –> EAX = 0x2
  • case 0x9A –> EAX = 0x3
  • default –> EAX = 0x4

而 EAX 的值最终会被用于跳转表,跳转表里保存了每一个指示值所对应的内存地址,这些内存地址就是每个分支的入口。回到反汇编,接着程序将执行一个跳转,目的地址是某个基址 + EAX * 4,让我们在数据窗口跟随到这个基址:

image

我们很容易发现,这个基址就在之前指示器基址的上方。本例中它存储了 0x14 字节(20 字节)的数据,每个地址是 4 字节,那么就是五个地址,十六进制分别是 004095E6,004095E5,00409604,00409613,00409622。这刚好是程序中各个分支的入口地址。EAX(本例中 0x0 ~ 0x4)的值恰好是指示取这五个值中的哪一个值。

image

在本例中,EAX 会取到 0x4 这个值,最终会 JMP 到 00409622 这个地址,也就是 default 分支的入口点,这样 switch 就执行完毕了。

九月 11

通过分析反汇编还原 C 语言 if…else 结构

    让我们从反汇编的角度去分析并还原 C 语言的 if … else 结构,首先我们不看源代码,我们用 OllyDBG 载入 PE 文件,定位到 main 函数领空,如下图所示。image

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

在图示中,我已经做好了关键的注释,经过一步一步地分析,不难还原出以下的 C 语言代码段。

image

 

 

九月 11

__cdecl、__stdcall、__fastcall 与 __pascal 浅析

call 指令与 retn 指令

    首先我们得了解 CALL 和 RETN 指令的作用,才能更好地理解调用规则,这也是先决条件。

    实际上,CALL 指令就是先将下一条指令的 EIP 压栈,然后 JMP 跳转到对应的函数的首地址,当执行完函数体后,通过 RETN 指令从堆栈中弹出 EIP,程序就可以继续执行 CALL 的下一条指令。

__cdecl 与 __stdcall 调用规则

    C/C++ 中不同的函数调用规则会生成不同的机器代码,产生不同的微观效果,接下来让我们一起来浅析四种调用规则的原理和它们各自的异同。首先我们通过一段 C 语言代码来引导我们的浅析过程。

    这里我们编写了三个函数,它们的功能都是返回两个参数的相加结果,只是每个函数都有不一样的调用规则。

image

    我们使用 printf 函数主要是为了在 OllyDBG 中能够快速下断点,以确定后边调用三个函数的位置,便于分析。在这里我给每个函数都用了内联的 NOP 指令来分隔开,图中也用红框标明,这样可以便于区分每个函数的调用过程。通过一些简单的步骤,我们用 OllyDBG 查看了编译后代码的“真面目”。代码中有 4 个 CALL,第一个是 printf,我们不关心这个。后面三个分别是具有 __cdecl,__stdcall,__fastcall 调用规则的函数 CALL(这里我已经做了注释)。

image

    在这里为了循序渐进,我们先介绍 __cdecl 与 __stdcall 调用规则,后面我们会接着浅析 __fastcall 调用规则。

    首先,我们得明白一个教条(其实也是自己概括的),那就是 —— 调用规则的区别产生其实就是由于调用者与被调用者之间的“责任分配”问题。

    代码段中的第 2 个就是 __cdecl 调用规则的 CALL。__cdecl 是 C/C++、MFC 默认的调用规则。我们可以看到,在执行 CALL 之前,程序会将参数按照从右到左的方式压栈,这里是两个整型参数,每压栈一个 ESP 都会减 4,这样下来 ESP 会减少 8,然后 CALL 这个函数。常规地,我们可以看到,这个 CALL 里面参数的处理和通常情况下一致,先将 EBP 压栈保存现场,然后使 EBP 重合于 ESP,再通过 EBP + 偏移地址来取得两个参数值,赋值再累加到 EAX 中,EAX 将作为返回值给调用者使用,还原 EBP 现场,调用 RETN 返回到调用者。最后,使得 ESP 加 8。哎!这刚好和开头对称嘛!为了堆栈平衡,ESP 最终又被拉回到了 CALL 之前的位置。我们暂且可以小结一下,实际上在 __cdecl 调用规则中,需要调用者来负责清栈操作(由调用者将 ESP 拉高以维持堆栈平衡)。

image

    代码段中的第 3 个是 __stdcall 调用规则的 CALL。__stdcall 调用规则在 Win32 API 函数中用的比较多。跟 __cdecl 一样,在执行 CALL 之前,程序会先将参数从右到左依次压栈,我们跟进 CALL 里面,可以看到以下的反汇编代码,我们很容易发现,除了最后一条指令,其他的指令与 __cdecl 调用规则是基本一样的。最后一条指令是“RETN 0x8”,这是什么意思呢?实际上呢,就相当于先执行“ADD ESP, 0x8”再执行“POP EIP” 。换言之,就是将 ESP 加 8,然后正常 RETN 返回到调用者。

image

    我们不难发现,__stdcall 调用规则使得被调用者来执行清栈操作(由被调用者函数自身将 ESP 拉高以维持堆栈平衡),这也是 __stdcall 与 __cdecl 调用规则的最根本的区别。

    __cdecl 偏向于把责任分配给调用者,动脑筋想想,我们的程序在 CALL __cdecl 调用规则的函数之前,把参数从右到左依次压栈,CALL 返回后,剩下的清栈操作都交给调用者处理,调用者负责拉高 ESP。再回来想想 __stdcall,在 CALL 中将调用者的 EBP 压栈以保存现场,然后使 EBP 对齐于 ESP,然后通过 EBP + 偏移地址取得参数,并且经过加法得到 EAX 返回值,从堆栈弹出 EBP 恢复现场,但是最后不一样的地方,程序将执行 “RETN 0x8” 将 ESP 拉回之前的 ESP + 8 的位置,换言之,被调用者将负责清栈操作。这就是之前所谓的“责任分配”的区别。

__fastcall 调用规则

    不难揣测 fastcall 的英文意思貌似是“快速调用”,这一点与它的调用规则息息相关,它的快速是有原因的,让我们继续来看看之前那张反汇编的截图,代码段中的第 4 个就是 __fastcall 调用规则的 CALL。进 CALL 前,出乎意料地,程序将两个参数从右到左分别传给了 EDX,ECX 寄存器,讲到这里,学过计算机系统相关知识的人很容易理解为什么这叫“快速调用”了,寄存器比内存快很多很多倍,可以认为传参给寄存器,要比在内存中更快得多,效率更高。

image

    由于参数是直接传递给了寄存器,堆栈并未发生改变,在 CALL 中,EBP 压栈,EBP 和 ESP 对齐之后,ESP 减 8,这个操作有点像对局部变量分配堆栈空间(这里有我之前一篇博客,对局部变量的存放规则做了浅析),然后程序将 EDX,ECX 分别赋值给 EBP – 8 与 EBP – 4 这两个地址,这个过程相当于用寄存器给局部变量赋值,接下来运算结果将保存在 EAX 中,ESP 归位,EBP 恢复现场,最后 RETN 返回调用者领空。

    本例只传送了两个整数型参数。其实呢,对于 __fastcall 调用规则,左边开始的两个不大于4字节(int)的参数分别放在ECX和EDX寄存器,其余的参数仍旧自右向左压栈传送。并且,__fastcall 调用规则使得被调用者负责清理栈的操作(由被调用者函数自身将 ESP 拉高以维持堆栈平衡),这一点和 __stdcall 一样。

__pascal 调用规则

    __pascal 是用于 Pascal / Delphi 编程语言的调用规则,C/C++ 中也可以使用这种调用规则。简单地说,__pascal 调用规则与 __stdcall 不同的地方就是压栈顺序恰恰相反,前面讲到的三种调用规则的压栈顺序都是从右到左依次入栈,__pascal 则是从左到右依次入栈。并且,被调用者(函数自身)将自行完成清栈操作,这和 __stdcall,__fastcall 一样。由于比较简单,我就没有做出示例。

小结

    做个表格来小结一下,很直观就能看出这四种调用规则的异同:

调用规则 入栈顺序 清栈责任
__cdecl 从右到左 调用者
__stdcall 从右到左 被调用者
__fastcall 从右到左(先 EDX、ECX,再到堆栈) 被调用者
__pascal 从左到右 被调用者
九月 10

ESP、EBP、CALL 指令与局部变量浅析

概述

    函数调用是计算机程序中一个最重要的概念之一,从汇编的角度看,能更加直观地理解函数调用的原理,理解 CALL 指令调用过程中 ESP、EBP 寄存器的作用。

    我们先从一段简陋的 C 语言代码说起,我们首先调用了 printf 函数,为什么要调用 printf 函数呢?实际上是为了更方便地在 OllyDBG 反汇编工具中断点,能更好地定位到 fun 函数的位置(因为 fun 函数的 CALL 将紧跟着 printf 函数的 CALL)。

在 fun 函数中,我们定义两个 int 型变量(两个 4 字节的变量),给 a 赋值了 0,给 b 赋值了 2。

image

我们用 OllyDBG 载入,在 printf 函数处下断点,运行在断点处暂停下来,在堆栈窗口中按 Enter 跟随到反汇编处,我们来到了下图所示的位置,第一行就是 main 函数这个 CALL 的段首,首先把 EBP 压栈,为了保存外层代码的 EBP 的值,使这个 CALL 不影响外层代码的 EBP 的值。然后第二行,程序将 ESP 赋值给 EBP。此时,EBP 与 ESP 重合。

第三行是一个 PUSH 指令,它将指向字符串的指针压栈,这个指针指向的是“this is a break point”字符串,第四行程序执行 CALL 指令调用 printf 函数,printf 函数将从堆栈中取出参数进行处理,最终在终端中打印出字符串。换言之,参数通过堆栈传递给 printf 函数,就像 C 语言那样。

image

第五行将 ESP 增加了 4,然后下一行程序直接 CALL 了 fun 函数。我们按 Enter 键跟进到 fun 函数内部,如下图。

image

在 fun 函数内部,EBP 又被压栈,然后 EBP 被赋值成 ESP 的值(有点像之前 main 函数段首的情形)

注意第三行,ESP 被减少了 8,这样的话,EBP 就比 ESP 多 8。第四行与第五行是 MOV 赋值指令,首先立即数 0 赋值给了 EBP – 4 的位置,然后立即数 2 赋值给了 EBP – 8 的位置。

让我们来仔细分析一下,不难看出,ESP 减少 8,使得 ESP 与 EBP 相隔 8 字节的地址位置,换言之,ESP 与 EBP 中间有 8 字节的空隙,此时 ESP 比 EBP 小 8。而接下来的两行 MOV 指令将 EBP – 4 这个地址,EBP – 8 这个地址,分别赋值了 0 和 2,这两个 4 字节的 int 整数型数据,a 和 b 两个局部变量刚好填满了 ESP 与 EBP 中间 8 字节的空隙。我们可以作图理解它的结构:

image

局部变量的堆栈赋值完成以后,函数的基本功能就执行完了。这时,ESP 被赋值成 EBP 的值(跟 fun 函数开头刚好相反,开头是 EBP 被赋值成 ESP) ,相当于将 ESP 还原函数调用之前的状态。下一行,从堆栈中弹出 EBP 的值(也就是 fun 函数开头 PUSH 的 EBP 值),也相当于将 EBP 还原成函数调用之前的状态,最后执行 RETN 指令返回到 CALL 处的下一行,fun 函数就执行完毕了。

补充

    本例中并没有演示函数参数在堆栈中的存放规则,只演示了局部变量的。

    实际上,函数参数存放在比 EBP 大的位置,和局部变量存放的地址增长方向恰恰相反。也就是说,如果多定义一个局部变量,ESP 会越来越小于 EBP,它们之间的“空隙”会越来越大,以存放更多的局部变量。但是,如果给函数多增加一个参数,函数调用者的 ESP (也就是被调用者的 EBP)会越来越大于 ESP,“空隙”也将存放更多的参数,因为函数的参数会在调用者去 CALL 被调用者之前被压栈,ESP 将增大,这个 ESP 将成为被调用者的 EBP。最终,我们看到的就是函数中局部变量的地址比 EBP 小,而函数的参数的地址比 EBP 大,它们以 EBP 为界,且地址增长方向相反。