前言
C++程序员始终是要面对汇编的,不管是为了分析崩溃转储文件,做必要的性能优化,理解计算机运行过程,或者是通过汇编的角度理解C++,学习汇编语言都是必要的,此处不进行赘述。
本文假设读者不是C++初学者,已有一定的C++实践经验;学习过计算机结构/微型计算机原理或者类似的课程,大致知道寄存器和CPU指令的概念。
本文以C++代码经过MSVC编译后的x86 MASM汇编语言为例进行演示,对于习惯于在Windows环境下编写C++代码的读者相对更友好一些,不过对习惯在Linux环境下进行C++开发的读者也有学习和阅读价值。
必要知识一:寄存器
我们以32位x86架构的寄存器进行讨论。根据用途和功能,x86寄存器可以分为以下几类:
-
通用寄存器:这些寄存器用于存储数据和进行通用计算。在32位x86架构中,常用的通用寄存器有:EAX、EBX、ECX、EDX。在编译C++代码时,这些寄存器可能用于存储变量、临时数据或计算结果。
-
段寄存器:这些寄存器用于存储内存分段的基地址。常用的段寄存器有:CS(代码段寄存器)、DS(数据段寄存器)、SS(堆栈段寄存器)、ES、FS、GS(附加段寄存器)。但在32位保护模式下,段寄存器的值不再直接用作基地址。相反,它们存储的是段描述符表(如全局描述符表,GDT)中的一个索引。段描述符表包含了关于内存段的信息,如基地址、限制和访问权限等。一般C++程序员不需要关心段寄存器。
-
指针寄存器和索引寄存器:这些寄存器用于地址计算和内存访问。主要包括:ESP(堆栈指针寄存器)、EBP(基址指针寄存器)、ESI(源索引寄存器)、EDI(目的索引寄存器)。
-
控制寄存器:这些寄存器用于控制处理器状态和执行流程。如:EFLAGS(标志寄存器)、EIP(指令指针寄存器)、CR0-CR4(控制寄存器)等。
C++程序员需要特别关心的寄存器有两类:
- EAX、EBX、ECX、EDX等通用寄存器可能存储局部变量、临时值或计算结果。其中EAX寄存器在类成员函数调用的时候,往往存储的是this指针。
- ESP寄存器用于指向当前堆栈顶部,而EBP寄存器通常用于存储堆栈帧基址。当发生内存越界时,存储在栈顶的主调函数的ESP寄存器值可能会被破坏,导致栈解旋异常,崩溃堆栈难以理解。
相对而言,以下寄存器我们一般不需要特别关心:
- ESI和EDI寄存器在字符串或数组操作中,可能分别用于存储源和目标地址。ESI和EDI寄存器的使用在编译器生成的汇编代码中比较少见,一般会使用通用寄存器(如EAX、EBX、ECX、EDX)进行优化。
- EIP寄存器存储下一条要执行的指令的地址。一般不会在汇编代码被直接控制。
- EFLAGS寄存器存储处理器状态信息,如标志位(进位、零、符号等)。汇编代码中不会直接修改EFLAGES寄存器值,而是作为计算结果进行查询。
需要注意的是,具体的寄存器使用和分配依赖于不同类型和版本编译器如何汇编代码。所以以上的表述并非铁律,哪个编译器选择把this指针放在ECX寄存器也是有可能的。
必要知识二:常用指令
当C++代码被编译为MASM汇编语言时,生成的指令取决于具体的代码逻辑、目标处理器架构以及编译器的优化策略。以下是一些常见的汇编指令类别,以及它们在编译C++代码时可能出现的情况。需要注意的是,这里的指令并不是完整的指令集,仅列举一部分典型的指令。
- 数据传输指令:
- MOV:传输数据,用于将数据从源操作数复制到目的操作数。
- PUSH:将数据压入堆栈。
- POP:将数据从堆栈弹出。
- 算术运算指令(乘法和除法指令可能会被优化为加法和移位指令):
- ADD:加法运算。
- SUB:减法运算。
- MUL:无符号乘法运算。
- IMUL:带符号乘法运算。
- DIV:无符号除法运算。
- IDIV:带符号除法运算。
- INC:递增。
- DEC:递减。
- 比较与逻辑运算指令:
- CMP:比较两个操作数。
- AND:按位与运算。
- OR:按位或运算。
- XOR:按位异或运算。
- NOT:按位取反运算。
- TEST:按位与运算,用于测试而不改变操作数。
- 控制转移指令:
- JMP:无条件跳转。
- Jxx:条件跳转(如JZ, JNZ, JL, JG等)。
- CALL:调用子程序。
- RET:从子程序返回。
-
系统调用与中断指令:
INT:软件中断。 -
字符串与数组操作指令(在编译器生成的汇编代码中比较少见,可能会被优化为其他指令):
- MOVS:传输字符串。
- LODS:载入字符串。
- STOS:存储字符串。
对于C++程序员而言,需要特别关心包括数据传输指令(MOV、PUSH、POP),控制转移指令和部分算数运算指令。这些指令在理解基本的底层计算机运行流程十分有帮助,在分析崩溃转储文件时也需要特别关心。
有一些汇编指令,比如RCR和RCL(循环左移和循环右移指令)、LOOP指令及其变体、MUL和DIV(乘法和除法指令)、字符串操作指令,在编译器生成的汇编代码中比较少见,一般编译器会选择更高效的方式。另外,编译器生成的汇编代码通常不包含伪指令(如.IF、.ELSE、.ENDIF等)。编译器会直接生成底层的、针对目标处理器优化的指令以确保代码的执行效率和性能。
C++代码和编译后的MASM代码示例
先给出简单的C++代码示例
#include <iostream>
int main() {
int a = 10;
int b = 20;
int sum = a + b;
std::cout << "The sum is: " << sum << std::endl;
return 0;
}
编译后的MASM代码(可能与你编译的编译结果有所不同,但大差不差)
; 引入必要的库和定义
include stdio.inc
include stdlib.inc
include iostream.inc
; 使用标准调用约定(stdcall)
.686p
.model flat, stdcall
includelib msvcrt.lib
; 导入所需的外部函数
extrn _printf:proc
extrn _exit:proc
; 数据段
.data
a dd 10 ; 定义整数a并初始化为10
b dd 20 ; 定义整数b并初始化为20
sum dd ? ; 定义整数sum,未初始化
fmt db "The sum is: %d", 10, 0 ; 定义格式化字符串,包括换行符和空字符
; 代码段
.code
_main proc
; 计算 a + b
mov eax, dword ptr [a] ; 将整数a的值加载到寄存器eax
add eax, dword ptr [b] ; 将整数b的值添加到寄存器eax
mov dword ptr [sum], eax ; 将寄存器eax的值存储到整数sum中
; 输出结果
push eax ; 将sum的值压入堆栈
push offset fmt ; 将格式化字符串的地址压入堆栈
call _printf ; 调用printf函数输出结果
add esp, 8 ; 清理堆栈
; 退出程序
xor eax, eax ; 将eax清零
push eax ; 将0压入堆栈,作为_exit函数的参数
call _exit ; 调用_exit函数退出程序
_main endp
; 结束_main函数的定义
end _main
首先我们可以看到,代码开头出现了几个include
指令,并且注意到以;
开始的一行注释。
; 引入必要的库和定义
include stdio.inc
include stdlib.inc
include iostream.inc
这段代码展示了在MASM汇编语言中如何引入外部库和定义。在MASM中,使用include指令来包含外部的汇编源文件或库。这类似于C++中的#include预处理指令,用于在当前源文件中插入另一个文件的内容。
在这个例子中,代码引入了三个外部库:
- stdio.inc:包含了有关C标准输入输出库(例如printf函数)的相关定义。
- stdlib.inc:包含了C标准库(例如exit函数)的相关定义。
- iostream.inc:包含了C++标准输入输出库(例如std::cout)的相关定义。
引入这些库后,可以在汇编程序中使用这些库中提供的函数和符号。这有助于编写更高级别的汇编程序,因为你可以直接使用这些库提供的功能,而不必手动编写底层汇编代码。
; 使用标准调用约定(stdcall)
.686p
.model flat, stdcall
includelib msvcrt.lib
这段MASM汇编代码涉及到三个不同的指令,分别是.686p、.model和includelib。下面分别解析这三个指令的作用:
- .686p:这个指令告诉汇编器生成的目标代码应该基于x86架构中的Intel 686处理器(即Pentium Pro处理器)或其兼容处理器。这样,生成的代码将能够使用686处理器提供的指令集。
- .model:用于指定内存模型和调用约定。在这个例子中,.model指令设置了两个属性。
- flat:表示选择扁平内存模型。在扁平内存模型中,代码、数据和堆栈都位于同一个32位线性地址空间。这是32位x86编程的标准内存模型。
- stdcall:表示使用标准调用约定。在stdcall调用约定中,函数的参数从右到左依次压入堆栈,由被调用者清理堆栈。这是Windows API中使用的主要调用约定。
- includelib msvcrt.lib:includelib指令用于链接外部库。在这个例子中,msvcrt.lib是Microsoft Visual C++运行时库,它提供了许多C和C++标准库函数的实现。通过将这个库包含到项目中,你可以在汇编代码中调用这些函数,例如printf和exit。这和C++中引用静态库是相同的。
这三个指令对于配置目标处理器、内存模型、调用约定和链接外部库非常重要,它们有助于编写适用于特定环境和平台的汇编程序。
; 导入所需的外部函数
extrn _printf:proc
extrn _exit:proc
在这段MASM汇编代码中,extrn指令的作用是声明外部函数。这允许汇编程序引用其他模块或库中定义的函数。通过声明这些函数,汇编程序可以在运行时调用它们,而不需要在当前代码中实现它们。这有助于减少代码重复,同时允许使用其他库提供的功能。
在这个例子中,extrn指令声明了两个外部函数:
-
_printf:proc:声明名为_printf的外部函数,其类型为proc(即过程)。这里的_printf对应C标准库中的printf函数,用于格式化输出。注意,在MASM中,外部C函数通常以下划线开头,以表示它们是由C编译器生成的。
-
_exit:proc:声明名为_exit的外部函数,其类型同样为proc。_exit对应C标准库中的exit函数,用于终止程序并返回一个状态值。
通过声明这些外部函数,汇编程序可以在需要时调用它们。例如,在代码中,_printf被用来输出计算结果,而_exit被用来退出程序。
; 数据段
.data
a dd 10 ; 定义整数a并初始化为10
b dd 20 ; 定义整数b并初始化为20
sum dd ? ; 定义整数sum,未初始化
fmt db "The sum is: %d", 10, 0 ; 定义格式化字符串,包括换行符和空字符
这段MASM汇编代码定义了一些数据变量,并放置在.data数据段中。数据段用于存储程序中的静态数据,如全局变量和常量。以下是这段代码中各行的解释:
-
a dd 10:定义一个名为a的整数变量,并将其初始化为10。dd指令表示定义一个双字(doubleword,4字节)大小的数据。在32位x86汇编中,一个双字通常用于表示一个整数。
-
b dd 20:定义一个名为b的整数变量,并将其初始化为20。这里的dd指令也表示定义一个双字(4字节)大小的数据。
-
sum dd ?:定义一个名为sum的整数变量,但不对其进行初始化。在这里,?表示未初始化的数据。sum变量的值在运行时将被计算并设置。
-
fmt db "The sum is: %d", 10, 0:定义一个名为fmt的格式化字符串。db指令表示定义一个字节(byte)大小的数据,也可以用于定义字符串。在这个例子中,db用于定义一个以空字符(值为0)结尾的字符串,包含文本"The sum is: %d"、换行符(ASCII值为10)和空字符。%d是printf函数的格式说明符,表示输出一个整数值。
这段代码主要用于定义和初始化程序所需的数据。dd和db指令分别用于定义双字(4字节)和字节(1字节)大小的数据。
; 代码段
.code
_main proc
; 计算 a + b
mov eax, dword ptr [a] ; 将整数a的值加载到寄存器eax
add eax, dword ptr [b] ; 将整数b的值添加到寄存器eax
mov dword ptr [sum], eax ; 将寄存器eax的值存储到整数sum中
; 输出结果
push eax ; 将sum的值压入堆栈
push offset fmt ; 将格式化字符串的地址压入堆栈
call _printf ; 调用printf函数输出结果
add esp, 8 ; 清理堆栈
; 退出程序
xor eax, eax ; 将eax清零
push eax ; 将0压入堆栈,作为_exit函数的参数
call _exit ; 调用_exit函数退出程序
_main endp
; 结束_main函数的定义
end _main
代码段从_main标签开始。这是程序的入口点,下面一行行地进行分析:
-
首先,通过
mov eax, dword ptr [a]
指令,将整数a的值加载到寄存器eax中。[]
这个操作符在汇编指令中表示间接寻址,用于访问括号内的地址所指向的内存内容。在指令mov eax, dword ptr [a]
中,变量a 代表的是一个内存地址(这和C++不一样,汇编语言中指令出现变量,代表的是它的地址,需要使用[]
操作符才能获取其内存内容,即变量值),[a]
则表示该地址处存储的数据。dword ptr
在汇编指令中是一个类型修饰符,用于指定操作数的大小和类型。在这个例子中,dword ptr
表示操作数是一个双字(Double Word,32位)数据类型。dword ptr
通常与间接寻址操作符[]
一起使用,以指定要从内存地址读取的数据大小。当处理器执行此类指令时,它将知道需要读取或写入多少字节的数据。 -
然后,通过
add eax, dword ptr [b]
指令,将整数b的值添加到寄存器eax中。此时,eax中存储的值为a + b的和。 -
接着,通过
mov dword ptr [sum], eax
指令,将寄存器eax中的值存储到整数sum中。此时,sum已经存储了a + b的和。 -
为了输出结果,首先将eax中的值(sum)压入堆栈,通过
push eax
指令。 -
接下来,将格式化字符串的地址(fmt)压入堆栈,通过
push offset fmt
指令。 -
调用_printf函数来输出结果。该函数将使用前面压入堆栈的格式化字符串和sum值来生成输出。也就是说,_printf函数的传参过程实际上是使用了堆栈作为媒介,这也是汇编语言中调用函数的一般流程。
-
_printf函数调用完成后,通过add esp, 8指令清理堆栈。因为之前压入了两个参数,所以需要调整栈指针。这也是清理入栈的参数的过程,栈顶指针重新指向参数入栈前的地址。
-
最后,程序准备退出。首先,将eax清零,通过xor eax, eax指令。然后,将0压入堆栈,作为_exit函数的参数,通过push eax指令。调用_exit函数来退出程序。
总结
本文介绍了C++程序员需要注意的x86寄存器,MASM指令,并且以一段简单的C++代码以及编译后的MASM汇编程序进行逐行的代码解析,希望能对读者学习汇编语言有所帮助,也需要读者不断的回顾和学习。
网友评论