在本章中,我们将编写一些小片段的代码并进行编译,以理解汇编输出。我们将逐步跟随汇编代码中的指令,理解从汇编角度的代码执行流程。
在本章对小片段代码进行编译的过程中,我们将在 32 位环境下使用 Microsoft 编译器。所有程序均在 Microsoft Windows 32 位环境中编译。我们还将在分析时启用代码优化。
结构
本章将涵盖以下主题:
- 什么是代码优化
- 理解 C/C++ 程序的汇编模式
- 代码优化的概念
- 用于生成 C/C++ 程序汇编模式的工具
目标
学习本章后,你应能够:
- 理解代码优化及其重要性
- 比较有优化与无优化的汇编代码
什么是代码优化?
优化意味着以最佳方式利用资源。代码优化是指对源代码进行转换,去除不必要的部分,以便在执行时消耗更少的资源(如内存、CPU 等)。当编译器对代码进行优化时,应确保:
- 优化前后的代码含义不变;
- 优化后的代码消耗更少资源;
- 优化过程不会显著影响编译时间。
让我们从一个简单的 C/C++ 程序开始,逐渐过渡到更复杂的程序。这一过程将帮助你理解 C/C++ 应用程序在汇编层面的模式。
空函数
空函数是指不执行任何操作的函数。下面我们在 C/C++ 代码中定义一个空函数 EmptyFunction():
无优化的空函数
现在,使用 MSVC 编译器(cl.exe)在无优化模式下编译它。请在 Windows 命令提示符下运行以下命令,先设置 cl.exe(VS 编译器)的环境,然后使用如下开关编译代码:
/Fa:指定输出的汇编列表文件名/Fe:指定生成的可执行文件名
cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat
cd C:\JitenderN\REBook\EmptyFunction\EmptyFunction
cl EmptyFunction.cpp /FaEmptyFunction.asm /FeEmptyFunction.exe
下面是运行上述命令后的输出:
下面是未启用优化时生成的汇编代码:
我们将逐行分析汇编代码,以理解代码模式的含义和工作方式。
▼第 1 行
; Listing generated by Microsoft (R) Optimizing Compiler Version 16.00.30319.01
这一行是注释,因为其以分号开头。该注释说明用于生成汇编代码的编译器是 Microsoft® 32 位 C/C++ 优化编译器 Version 16.00.30319.01(面向 80x86 平台)。编译器本质上将程序从一种语言翻译到另一种语言;而“优化编译器”则在此基础上对代码进行改进,以提升运行效率。
▼第 3 行
TITLE C:\JitenderN\REBook\EmptyFunction\EmptyFunction\EmptyFunction.cpp
TITLE 定义了所编译的 C/C++ 源文件的绝对路径。
▼第 4 行
.686P
此伪指令启用针对 Pentium Pro 处理器的全部指令集扩展(仅适用于 32 位 MASM)。
▼第 5 行
.XMM
此伪指令表示生成的代码需要支持 Streaming SIMD Extensions(流式 SIMD 扩展)指令集的 CPU。
▼第 6 行
include listing.inc
listing.inc 文件包含了一些汇编器宏(macros),用于在汇编语言中实现模块化编程。为了提升性能并保证代码对齐,Visual C++ 并不会将这些宏展开到最终的汇编代码中。你可以在 Visual C++ 的 include 目录下找到该文件。
▼第 7 行
.model flat
这是启用平坦内存模型(flat memory model)的指令。要理解内存模型,需要知道内存访问有三种方式:
- 平坦(Flat) :非分段内存模型,程序将整个内存视为一个连续的大字节数组。代码、数据和堆栈都位于同一地址空间。
- 分段(Segmented) :将内存划分为若干“段”(segment),每个段有独立的地址空间。
- 真实模式(Real) :一种非常古老的内存模型,用于 Intel 8086 处理器。
▼第 9–10 行
INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES
通过 INCLUDELIB 指令,我们将链接位于系统库目录下的 LIBCMT.LIB 和 OLDNAMES.LIB 两个库文件。
▼第 12 行
PUBLIC _main
PUBLIC 是一个指令,用于将名为 _main 的过程声明为公共可见(public)。汇编器指令(directive)可以自动化汇编流程,并提高代码可读性。所有函数名在汇编里都以下划线开头;将 main 标记为公共函数意味着其他模块也能引用它。
▼第 13 行
; Function compile flags: /Odtp
所有注释都以分号开头。这一行说明本代码是使用 /Odtp 开关编译的。
▼第 14 行
_TEXT SEGMENT
这是文本段(text segment,也即代码段)的开始。
▼第 15 行
_main PROC
过程用 PROC 指令开始,必须用 ENDP 结束;在第 25 行可看到 _main ENDP。
▼第 16 行
; File c:\jitendern\rebook\emptyfunction\emptyfunction\emptyfunction.cpp
注释,表明对应的 C/C++ 源文件路径。
▼第 17 行
; Line 9
注释,指出接下来这条汇编指令对应源文件的第 9 行。
▼第 18–19 行
push ebp
mov ebp, esp
这是函数序言(prologue),在函数开始时保存旧的基指针并建立新的基指针。
▼第 20–21 行
; Line 10
xor eax, eax
xor 为异或运算;EAX 寄存器用于存放函数返回值。由于 main 要返回 0,xor eax,eax 会将 EAX 清零。虽然也可以用 mov eax,0,但 xor 的机器码更短(2 字节 vs. 5 字节)。
▼第 22 行
; Line 11
注释,指出下一条指令对应源文件的第 11 行。
▼第 23 行
pop ebp
这是函数尾序(epilogue)的一部分,用于恢复调用前的基指针。
▼第 24 行
ret 0
RET 是返回指令,将指令指针恢复到调用者处。可选的 nBytes 操作数表示在返回后将 ESP 增加的字节数;这里是 0,表示不调整栈指针。
▼第 25–26 行
_main ENDP
_TEXT ENDS
分别标志着 _main 过程的结束和文本段(代码段)的结束。
▼第 27 行
PUBLIC ?EmptyFunction@@YAXXZ ; EmptyFunction
汇编内部使用“修饰名”(decorated name)表示函数:它在函数名后附加调用约定、返回类型、参数列表等信息,帮助链接器在链接时找到正确的符号。这里同样用 PUBLIC 将 EmptyFunction 声明为公共可见。
▼第 28 行
; Function compile flags: /Odtp
同第 13 行,说明该函数同样是以 /Odtp 开关编译的。
▼第 29 行
_TEXT SEGMENT
文本段(代码段)再次开始,用于放置 EmptyFunction 的汇编代码。
▼第 30 行
?EmptyFunction@@YAXXZ PROC ; EmptyFunction
EmptyFunction 过程以 PROC 开始。
▼第 31 行
; Line 15
注释,指出下一条指令对应源文件的第 15 行。
▼第 32–33 行
push ebp
mov ebp, esp
这是 EmptyFunction 的函数序言,作用同前。
▼第 34 行
; Line 16
注释,指出下一条指令对应源文件的第 16 行。
▼第 35 行
pop ebp
EmptyFunction 的函数尾序,恢复基指针。
▼第 36 行
ret 0
因为 EmptyFunction 是空函数,不修改栈指针,直接返回;0 表示 ESP 无需调整。
▼第 37–38 行
?EmptyFunction@@YAXXZ ENDP ; EmptyFunction
_TEXT ENDS
分别标志着 EmptyFunction 过程的结束和文本段的结束。
▼第 39 行
END
END 指令标志着汇编源文件的结束。
启用优化的空函数
现在使用 x86 平台上的 /Ox 开关来编译带优化的代码。请在 Windows 命令提示符下运行以下命令,先设置 cl.exe(VS 编译器)的环境,然后使用如下开关编译代码:
/Ox:启用最大优化/Fa:指定输出的汇编列表文件名/Fe:指定生成的可执行文件名
cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat
cd C:\JitenderN\REBook\EmptyFunction\EmptyFunction
cl EmptyFunction.cpp /FaEmptyFunction-Optimized.asm /Ox /FeEmptyFunction-Optimized.exe
下面是运行上述命令后的输出:
我们得到的汇编代码如下:
正如我们所见,当启用优化后,编译器会去除不必要的指令,而不改变代码的含义。如今的编译器在优化方面表现出色。因此,作为逆向工程师,更应关注代码背后的思路和逻辑,而不是逐行还原源代码;只要理解了逻辑,就能编写相应的原型。
回到这段汇编,可以看到大部分指令与前面分析的内容相同,此处不再重复。我们重点关注与优化相关的指令。
▼第 18 行
xor eax, eax
main 函数通过 xor eax, eax 将 EAX 清零以返回 0,因为 EAX 寄存器用于存放函数的返回值。
▼第 28 行
ret 0
EmptyFunction 只用一条 RET 指令将指令指针返回给调用者。
返回值
在本节中,我们将在 C/C++ 代码中创建一个函数来返回一个常量值。我们将定义并声明一个 ReturningValue() 函数。
无优化的返回值
在同一台 x86 Windows 机器上,使用 MSVC 编译器在无优化模式下编译此代码。请在 Windows 命令提示符下运行以下命令,先设置 cl.exe(VS 编译器)的环境,然后使用如下开关编译代码:
/Fa:指定输出的汇编列表文件名/Fe:指定生成的可执行文件名
cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat
cd C:\JitenderN\REBook\ReturningValue\ReturningValue
cl ReturningValue.cpp /FaReturningValue.asm /FeReturningValue.exe
下面是运行上述命令后的输出:
下面是编译后得到的内容:
我们在前面已经讨论了大部分指令,这里只关注主要指令。
▼第 20–24 行
; Line 10
xor eax, eax
; Line 11
pop ebp
ret 0
这是来自 main 函数的指令:先用 xor eax, eax 将 EAX 清零(返回值为 0),然后通过 pop ebp 完成函数尾序,最后 ret 将执行流返回给调用者,调用者即可从 EAX 寄存器中取走返回值。
▼第 34–38 行
; Line 15
mov eax, 2020 ; 000007E4H
; Line 16
pop ebp
ret 0
切换到 ReturningValue 函数:先用 mov eax, 2020 将常量 2020 装入 EAX(函数返回值),然后 pop ebp 进行尾序,最后 ret 将执行流返回给调用者,调用者同样从 EAX 中获取结果。
启用优化的返回值函数
在 x86 平台上,使用 MSVC 编译器的 /Ox 开关编译带优化的代码。请在 Windows 命令提示符下运行以下命令,先设置 cl.exe 环境,然后使用这些开关编译:
/Ox:启用最大优化/Fa:指定输出的汇编列表文件名/Fe:指定生成的可执行文件名
cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat
cd C:\JitenderN\REBook\ReturningValue\ReturningValue
cl ReturningValue.cpp /FaReturningValue-Optimized.asm /Ox /FeReturningValue-Optimized.exe
下面是运行上述命令后的输出:
生成的汇编代码如下:
我们可以看到,优化后的代码中所有不必要的指令都已被移除。
主函数只剩下两条指令:
▼第 18、20 行
xor eax, eax
ret 0
先用 xor eax, eax 将 EAX 清零(作为返回值),然后用 ret 指令将指令指针返回给调用者。
ReturningValue 函数同样只剩下两条指令:
▼第 28、30 行
mov eax, 2020 ; 000007E4H
ret 0
mov eax, 2020 将返回值 2020 装入 EAX,ret 指令将指令指针返回给调用者。
基本的 “Hello, World” 程序
在这个简单的 C/C++ 代码中,我们仅在控制台上打印 “hello, world”,使用的是 printf() 函数。
无优化的基础 “Hello, World” 程序
在 x86 平台上使用 MSVC 编译器在无优化模式下编译此代码。请在 Windows 命令提示符下运行以下命令,先设置 cl.exe(VS 编译器)的环境,然后使用如下开关编译代码:
/Fa:指定输出的汇编列表文件名/Fe:指定生成的可执行文件名
cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat
cd C:\JitenderN\REBook\HelloWorld\HelloWorld
cl HelloWorld.cpp /FaHelloWorld.asm /FeHelloWorld.exe
下面是运行上述命令后的输出:
以下是生成的汇编代码,下面我们将进行分析:
▼第 1–10 行
我们在“空函数”章节中已讨论过这部分内容。
▼第 12–14 行
CONST SEGMENT
$SG4677 DB 'hello, world', 0aH, 00H
CONST ENDS
字符串常量(此处为 “hello, world”)被分配到常量段中。CONST SEGMENT 指令用于定义常量段在内存中的起始位置。在我们的示例里,链接器将 CONST SEGMENT 重命名为了 .rdata,可以在调试器中导出查看。下图显示了我们在 x32dbg 中打开编译后生成的 EXE 文件,并使用 CFF Explorer 禁用了地址空间布局随机化(ASLR)。请按照附录中的步骤对 EXE 文件禁用 ASLR,这样每次在调试器中加载该 EXE 时,它都会映射到相同的内存地址。
$SG4677 是编译器为处理该字符串常量而自动生成的内部名称。
DB(Define Byte)用于定义字节数据类型。
'hello, world', 0aH, 00H 则是以空字符结尾的 ASCII 字符串数据。
到 CONST ENDS 时,常量段结束。
▼第 15 行
PUBLIC _main
PUBLIC 指令将 _main 过程声明为公共,可被其他模块访问。
▼第 16 行
EXTRN _printf:PROC
注意:代码放在 .code 段,常量字符串放在 CONST(即链接后的 .rdata)段,非常量数据则放在 .data 段。
EXTRN 指令声明了外部函数,此处为 printf。所有函数名汇编中都以下划线开头。
▼第 18 行
_TEXT SEGMENT
开始 _TEXT 段(代码段),存放 main 函数的汇编代码。
▼第 19 行
_main PROC
main 过程开始。
▼第 20–23 行
; File c:\jitendern\rebook\helloworld\helloworld\helloworld.cpp
; Line 8
push ebp
mov ebp, esp
注释以分号开头,一条注明源文件路径,一条注明对应的源代码行号。紧接着 push ebp 与 mov ebp, esp 构成函数序言。
▼第 25–27 行
push OFFSET $SG4677
call _printf
add esp, 4
调用 printf 前,用 push 将字符串常量地址压入栈;call 指令调用 printf。
printf 返回后,控制流回到 main,此时栈上仍有字符串指针,需要调用者(遵循 CDECL 约定)清理栈,于是执行 add esp, 4,将 ESP 增加 4 字节以移除该指针。32 位程序的指针大小为 4 字节,也可用 pop <寄存器> 来等价地弹出该地址。
▼第 28–29 行
; Line 10
xor eax, eax
main 要返回 0,EAX 寄存器用于存放返回值,因此用 xor eax, eax 将其清零。
▼第 30–32 行
; Line 11
pop ebp
ret 0
pop ebp—函数尾序;ret 0 将指令指针返回给调用者(此处不会调整栈指针,因为为 0)。
▼第 33 行
_main ENDP
关闭 _main 过程。
▼第 34–35 行
_TEXT ENDS
END
结束代码段和汇编源文件。
基本 “Hello, World” 程序(启用优化)
在 x86 平台上,使用 MSVC 编译器的 /Ox 开关启用最大优化,编译命令与前类似:
cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat
cd C:\JitenderN\REBook\HelloWorld\HelloWorld
cl HelloWorld.cpp /FaHelloWorld-Optimized.asm /Ox /FeHelloWorld-Optimized.exe
下面是运行上述命令后的输出:
生成的汇编代码如下:
所有代码行与上一节讨论的完全相同。唯一的区别在于,为了减少资源消耗(内存、CPU 等),编译器已移除了函数的序言和尾序代码。其余指令与未优化版本一致。需要注意的是,代码的含义与未优化时完全相同——我们仍然将指向常量字符串的指针压入栈中以调用 printf 函数。printf 执行完毕后,会将控制流返回到 main(调用者),由调用者清理栈并将 EAX 置零,作为返回值。
结论
在本章中,我们理解了代码优化的概念。通过空函数、返回常量值的函数以及打印 “hello, world” 程序的示例,我们比较了优化与未优化代码的汇编清单。下一章,我们将讨论包含 printf 函数的程序的优化策略,并探讨整数、浮点和字符变量在内存中的存储方式。