Not Only Algorithm,不仅仅是算法,关注数学、算法、数据结构、程序员笔试面试以及一切涉及计算机编程之美的内容 。。
你的位置:NoAlGo博客 » 程序设计 » 

内联汇编

内联汇编是指在C/C++代码中嵌入的汇编代码,在Visual Studio中使用内联汇编可以使用在C/C++中的变量,不需要额外的编译器和链接器,效率很好,而且可以解决一些普通代码不能解决的问题,在特定的场合中使用起来非常方便。
本文将通过几个例子简单介绍Visual C++中使用内联汇编的相关知识。

使用方法

使用内联汇编可以使程序代码段的运行效率非常高,可以实现C/C++语言无法实现的功能,可以编写naked函数的初始化和结束代码。对于一般的函数,编译器会自动帮我们生成函数的初始化(构建参数指针和分配局部变量等)和结束代码(平衡堆栈和返回一个值等)。使用内联汇编,我们可以自己负责这些内容,必要时自己做一些有关函数初始化和扫尾的工作。
但内联汇编依赖于具体的机器,不易于移植。如果你的程序打算在不同类型的机器(比如x86和Alpha)上运行,应当尽量避免使用内联汇编。这时候你可以使用MASM,因为MASM支持更方便的的宏指令和数据指示符。

使用内联汇编要用到__asm关键字,它可以出现在任何允许 C/C++ 语句出现的地方。下面以一个简单的例子说明其用法:

__asm
{
	mov eax, a
	add eax, b
	mov c eax
}

也可以写成

__asm mov eax, a
__asm add eax, b
__asm mov c, eax

或写成

__asm mov eax, a __asm add eax, b __asm mov c, eax

上述代码段的功能是把变量a和变量b相加并将结果存放到变量c中。
可以看到,第一种写法和C/C++的风格很一致,并且不用重复写__asm关键字,使用起来比较方便。虽然其使用了类似于C/C++中的大括号,但__asm块的大括号不会影响C/C++变量的作用范围。同时,__asm块可以嵌套,嵌套也不会影响变量的作用范围。

下面把上述代码段整合到一个完整的程序中以查看效果,函数f的实现中使用了内联汇编。

#include <cstdio>

int f(int a, int b)
{
	__asm
	{
		mov eax, a //把a存入寄存器中
		add eax, b ;eax为默认返回值
	}
}

int main()
{
	int a = 1, b = 2, c = f(a, b);
	printf("%d + %d = %d\n", a, b, c); //输出结果为:1 + 2 = 3
}

样例中可以看到,内联汇编可以使用两种注释:

  • C/C++风格的注释,即“//”。如函数f中的第一条注释。
  • 汇编风格的注释,即“;”。如函数f中的第二条注释。

f中的内联汇编使用寄存器eax,一般来说,不能假定某个寄存器在__asm块开始的时候有已知的值。寄存器的值将不能保证会从__asm块保留到另外一个__asm块中。
如果一个函数声明为__fastcall调用方式,它将通过CPU寄存器来传递参数,不同编译器编译的程序规定的寄存器不同。在Intel 386平台上,使用ECX和EDX寄存器。此时会导致__asm块产生问题,因为函数无法被告知哪个参数在哪个寄存器中。如果函数接收了EAX中的参数并立即储存一个值到EAX中的话,原来的参数将丢失掉。为了避免以上的冲突,包含__asm块的函数不要声明为__fastcall调用方式。样例中我们没有声明任何调用约定,则是默认使用了__cdecl的方式,此时函数参数是通过栈的方式传递,于是不会有以上问题。
如果使用EAX、EBX、ECX、EDX、ESI和EDI寄存器,你不需要保存它。但如果你用到了DS、SS、SP、BP和标志寄存器,那就应该用PUSH保存这些寄存器。如果程序中改变了用于STD和CLD的方向标志,必须将其恢复到原来的值。

VC的内联汇编可以直接以变量名的形式使用局部变量,同时也可以使用很多其它的C/C++的元素:

  • 符号,包括标号、变量和函数名;
  • 常量,包括符号常量和枚举型成员;
  • 宏定义和预处理指示符;
  • 注释,包括“/**/”和“//”;
  • 类型名,包括所有MASM中合法的类型;
  • typedef名称,通常使用PTR和TYPE操作符,或者使用指定的的结构或枚举成员。

但是VC内联汇编中有些变量名是保留的,要避免以这些保留关键字名作为变量,以免出错。可以用LENGTH、SIZE和TYPE来获取C/C++变量和类型的大小:

  • LENGTH:获取C/C++中数组的元素个数(如果不是一个数组,则结果为1)。
  • SIZE:获取C/C++变量的大小(一个变量的大小是LENGTH和TYPE的乘积)。
  • TYPE:返回C/C++类型和变量的大小(如果变量是一个数组,它得到的是数组中单个元素的大小)。

例如,程序中定义了一个8维的整数型变量:int a[8];下面是C和汇编表达式中得到的iArray及其元素的相关值:

__asm C size
LENGTH a sizeof(a)/sizeof(a[0]) 8
SIZE a sizeof(a) 32
TYPE a sizeof(a[0]) 4

同时,内联汇编中使用C/C++符号的还有一些限制:

  • 每条汇编语句只能包含一个C/C++符号。在一条汇编指令中,多个符号只能出现在LENGTH、TYPE?或SIZE表达式中。
  • 在__asm块中引用函数必须先声明。否则,编译器将不能区别__asm块中的函数名和标号。
  • 在__asm块中不能使用对于MASM来说是保留字的C/C++符号(不区分大小写)。MASM保留字包含指令名称(如PUSH)和寄存器名称(如ESI)等。
  • 在__asm块中不能识别结构和联合标签。

在内联汇编中可以调用C/C++的函数,如下是实现经典Hello World输出的程序内联汇编版本的代码:

#include <cstdio>
int main()
{
	char *s1 = "%s\n";
	char *s2 = "Hello World";
	__asm
	{
		push s2 ;参数从右到左入栈
		push s1
		call printf
		add esp, 8 ;调用栈清理堆栈
	}
}

注意调用函数时需要区分使用的是何种函数调用约定。以上使用的是默认的__cdecl约定,参数需要从右到左入栈,并且要调用者自己清理参数堆栈。由于栈是向下生长,当栈减小时其栈顶下标会变大,所以代码中清理堆栈时使esp的值增加了8。

使用__stdcall时参数仍然是从右到左入栈,但不需要自己清理堆栈。大多数Windows API函数均为__stdcall调用方式,下面以一个简单的例子说明其使用方法。

#include <cstdio>

int __stdcall f(int a, int b)
{
	return a + b;
}

int main()
{
	int a = 1, b = 2, c;
	__asm
	{
		push b ;参数从右到左入栈
		push a
		call f
		mov c, eax ;返回值存放在eax中
	}
	printf("%d + %d  = %d\n", a, b, c);
}

交换两个数

下面再以一个简单的例子介绍对VC内联汇编的介绍。
交换两个数是我们经常遇到的问题,经典的方法是引入第三个变量,然后进行交换。

#include <cstdio>
int main()
{
	int a = 1, b = 2;
	int c = a; a = b; b = c;
	printf("a = %d, b = %d\n", a, b); //输出:a = 2, b = 1
}

高级一点的方法可以使用位运算技巧:

#include <cstdio>
int main()
{
	int a = 1, b = 2;
	a ^= b ^= a ^= b;
	printf("a = %d, b = %d\n", a, b); //输出:a = 2, b = 1
}

现在多了一种方法,即使用内联汇编:

#include <cstdio>
int main()
{
	int a = 1, b = 2;
	__asm
	{
		push a
		push b
		pop a
		pop b
	}
	printf("a = %d, b = %d\n", a, b); //输出:a = 2, b = 1
}

使用堆栈交换可能效率较低,下面使用寄存器完成:

#include <cstdio>
int main()
{
	int a = 1, b = 2;
	__asm
	{
		mov eax, a
		xchg eax, b
		mov a, eax
	}
	printf("a = %d, b = %d\n", a, b); //输出:a = 2, b = 1
}

有兴趣的读者可以使用Windows计时函数这里介绍的计时方法比较这几种不同方案的效率。

GCC内联汇编

如果使用的是GCC而不是VC,内联汇编的规则不太一样。GCC的内联汇编比较复杂,这里不详细展开,只介绍一个简单的区别。
汇编语言有两种风格:Intel风格和AT&T风格,VC中使用的是Intel风格的汇编语法,而GC则使用AT&T风格的汇编语法。AT&T风格与Intel风格的几个主要区别如下:

  • 寄存器命名:寄存器以%为前缀,如寄存器eax, cl应该表示为%eax, %cl。
  • 立即数命名:立即数以%为前缀,如立即数5应该消失为$5。如“addl $5, %eax”表示给寄存器%eax加上长整型数值5。
  • 操作数的顺序:与Intel惯例相反,Intel中第一个操作数是目标,而AT&T中操作数的顺序是源在前,目标在后。例如,Intel语法“mov eax, edx”在AT&T汇编里类似“mov %edx, %eax”。
  • 操作数大小:AT&T中内存操作数的大小由指令名的最后一个字符决定。这个后缀对于字节(8位)是b,对于字(16位)是w,对于双字(32位)是l。例如,对于上述指令的正确语法是“movl %edx, %eax”。
  • 内存操作数:如果没有操作数前缀,那么它就是个内存地址,因此“movl $bar, %eax”把变量bar的地址放入寄存器%ebx,但是“movl bar, %ebx”把变量bar的内容放入寄存器%ebx。
  • 索引:索引或间接寻址通过把索引寄存器或间接内存地址放入括号实现。如“movl 8(%ebp), %eax”表示把距离%ebp指向的内存单元偏移为8的数据转移到寄存器%eax中去。
上一篇: 下一篇:

我的博客

NoAlGo头像编程这件小事牵扯到太多的知识,很容易知其然而不知其所以然,但真正了不起的程序员对自己程序的每一个字节都了如指掌,要立足基础理论,努力提升自我的专业修养。

站内搜索

最新评论