《汇编语言:基于X86处理器》第13章 高级语言接口(2)
与C、c++,Java等高级语言相比,汇编开发的效率偏低和维护成本偏高。大型的项目已经很少用汇编语言了,但并不是说汇编语言就完全没有用处了,在某些特定的领域,汇编语言还是很有用处的,比如配置硬件驱动器,芯片接口开发等。并且可以与C和c++混合使用。本章讲解高级语言接口,汇编语言与高级语言的混合编程,库调用。
13.3 32位汇编程序与C/C++的链接
为设备驱动器和嵌入式系统编码的程序员常常需要把C/C++模块与用汇编语言编写的专门代码集成起来。汇编语言特别适合于直接硬件访问、位映射,以及对寄存器和 CPU 状态标识进行底层访问。整个应用程序都用汇编语言编写是很乏味的,比较有用的方法是,用C/C++编写主程序,而那些不太好用C编写的代码则用汇编语言。现在来讨论从32位C/C++程序调用汇编程序的一些标准要求。
C/C++程序从右到左传递参数,与参数列表的顺序一致。函数返回后,主调程序负责将堆栈恢复到调用前的状态。这可以采用两种方法:一种是将堆栈指针加上一个数值,该值等于参数大小;还有一种是从堆栈中弹出足够多的数。
在汇编源代码中,需要在.MODEL 伪指令中指定C调用规范,并为外部 C/C++程序调用的每个过程创建原型。示例如下:
.586
.model flat.C
IndexOf PROTO,srchVal:DWORD, arrayPtr: PTR DWORD, count: DWORD
函数声明在C程序中,声明外部汇编过程时要使用 extern 限定符。比如,下面的语句声明了IndexOf:
extern long IndexOf( long n long array1,unsigned count );
如果过程会被 C++程序调用,就要添加“C”限定符,以防止 C++的名称修饰:
extern "c" long IndexOf( long n, long array[],unsigned count );
名称修饰 (name decoration)是一种标准C++编译技术,通过添加字符来修改函数名,添加的字符指明了每个函数参数的确切类型。任何支持函数重载(多个函数有相同的函数名、不同的参数列表)的语言都需要这个技术。从汇编语言程序员的角度来看,名称修饰存在的问题是:C++编译器让链接器去找的是修饰过的名称,而不是生成可执行文件时的原始名称。
13.3.1 IndexOf示例
现在新建一个简单汇编函数,对数组实现线性搜索,找到与样本整数匹配的第一个实例。如果搜索成功,则返回匹配元素的索引位置;否则,返回-1。该函数将被 C++程序调用。在 C++中,编写程序段如下:
long IndexOf(long searchVal, long array[], unsigned count)
{for(unsigned i = 0; i < count; i++) {if(array[i] == searchVal)return i;}return -1;
}
参数包括:希望被找到的数值、一个数组指针,以及数组大小。用汇编语言编写该函数显然是很容易的。编写好的汇编代码放入自己的源代码文件IndexOf.asm。这个文件将被编译为目标代码文件IndexOf.obj。使用Visual Studio实现主调C++程序与汇编模块的编译和链接。C++项目将用Win32控制台作为其输出类型,虽然也没有理由不让它成为图形应用程序。图 13-3 为IndexOf模块的源代码清单。首先,注意到用于测试循环的汇编代码25 ~28 行,虽然代码量小,但是高效。对要执行很多次的循环,应试图使其循环体内的指令条数尽可能少:
25: L1: cmp [esi+edi*4], eax
26: je found
27: inc edi
28: loop L1
图13-3 IndexOf模块清单
如果找到匹配项,程序跳转到 34 行,将 EDI 复制到EAX,该寄存器用于存放函数返回值。在搜索期间,EDI 为当前索引位置。
34:found:
35: mov eax,edi.
如果没有找到匹配项,则把-1 赋值给EAX并返回:
30:notFound:
31: mov eax, NOT_FOUND
32: jmp short exit
图 13-4 为主调 C++程序清单。首先,用伪随机数值对数组进行初始化:
12:long array[ARRAY_SIZE];
13:for(unsigned i = 0; i < ARRAY_SIZE; i++)
14: array[i] = rand();
18~19行提示用户输人在数组中搜索的数值:
18: cout << “Enter an integer value to find:“;
19: cin >> searchVal;
23行调用C链接库的time函数(在time.h中),把从1970年1月1日起已经过的秒数保存到变量startTime:
23: time( sstartTime );
26 和27 行按照LOOP SIZE 的值(100000),反复执行相同的搜索:
26: for( unsignedn=0; n<LOOP SIZE;n++)
27: count = Indexf( searchVal, array ARRAY_SIZE );
图13-4C++测试程序调用IndexOf的代码清单
由于数组大小也约为100000,因此执行步骤的总数可以多达100000*100 000,或100亿。31~33 行再次检查时间,并显示循环运行所耗的秒数:
31: time( &endTime );
32: cout << "Elapsed ASM time:"<< long(endTime-startTime)
33: <<"seconds.Found=<< boolstr[found << endl;
测试结果:
在高速计算机上测试时,循环执行时间为6 秒。对 100 亿次迭代而言,这个时间不算多,每秒约有16.7 亿次迭代。重要的是,需要意识到程序重复过程调用的开销(参数入栈,执行CALL和RET指令)也是100000次。过程调用导致了相当多的额外处理。
13.3.2 调用C和C++函数
可以编写汇编程序来调用C和C++函数。这样做的理由至少有两个:
●C和C++有丰富的输入-输出库,因此输入-输出有更大的灵活性。处理浮点数时,这是相当有用的。
●两种语言都有丰富的数学库。
调用标准C库(或C++库)函数时,必须从C或C++的main()过程启动程序,以便运行库初始化代码。
1.函数原型
汇编语言代码调用的C++函数,必须用“C”和关键字 extern 定义。其基本语法如下:
extern “C" returnType funcName( paramlist)
{ ... }
示例如下:
extern “c" int askForInteger( )
{
cout << "Please enter an integer:";
//...
}
与其修改每个函数定义,把多个函数原型放在一个块内显得更容易。然后,还可以省略单个函数实现的 extern 和“C”:
extern "C" {
int askForInteger();
int showInt(int value, unsigned outWidth);
//etc.
}
2.汇编语言模块
如果汇编语言模块调用Irvine32链接库过程,就要使用如下.MODEL 伪指令:
.model flat, STDCALL
虽然STDCALL 与Win32 API兼容,但是它与C程序的调用规范不匹配。因此,在声明由汇编模块调用的外部C或C++函数时,必须给PROTO伪指令加上C限定符:
INCLUDE Irvine32.
incaskForInteger PROTO C
showInt PROTO cvalue:SDWORD,outWidth:DWORD
C限定符是必要的,因为链接器必须把函数名与C++模块输出的参数列表匹配起来。此外,使用了C调用规范,汇编器必须生成正确的代码以便在函数调用后清除堆栈(参见8.2.4 节)。
C++程序调用的汇编过程也必须使用C限定符,这样汇编器使用的命名规则将能被链接器识别。比如,下面的SetTextColor过程有一个双字参数:
SetTextOutColor PROC C,
color:DWORD
.
.
SetTextOutColor ENDP
最后,如果汇编代码调用其他汇编过程,C调用规范要求在每个过程调用后,把参数从堆栈中移除。
使用MODEL伪指令 如果汇编代码不调用Irvine32过程,就可以在.MODEL伪指令中使用C调用规范:
;(do not INCLUDE Irvine32.inc)
.586
.model flat,C
此时不再需要为PROTO和PROC伪指令添加C限定符:
askForInteger PROTO
showInt PROTO, value:SDWORD, outWidth:DWORD
SetTextOutColor PROC,
color:DWORD
.
.
SetTextOutColor ENDP
3.函数返回值
C++语言规范没有提及代码实现细节,因此没有规定标准方法让C 和 C++函数返回数值。当编写的汇编代码调用这些语言的函数时,要检查编译器文件以便了解它们的函数是如何返回数值的。下面列出了一些可能的情况,但并非全部:
●整数用单个寄存器或寄存器组返回。
●主调程序可以在堆栈中为函数返回值预留空间。函数在返回前,可以将返回值存入堆栈。
●函数返回前,浮点数值通常被压人处理器的浮点数堆栈。
下面列出了Microsoft Visual C++函数怎样返回数值:
●short int 值用 AX 返回。
●bool 和 char 值用 AL 返回。
●int 和 long int 值用 EAX 返回。
●指针用 EAX 返回。
●float、double 和 long double 值分别以 4 字节、8 字节和 10 字节数值压人浮点堆栈。
13.3.3 乘法表示例
现在编写一个简单的应用程序,提示用户输入整数,通过移位的方式将其与2的幂(2~2)相乘,并用填充前导空格的形式再次显示每个乘积。输人-输出使用 C++。汇编模块将调用3个C++编写的函数。程序将由 C++模块启动。
1.汇编语言模块
汇编模块包含一个函数 DisplayTable。它调用 C++函数askForInteger 从用户输人一个整数。它还使用循环结构把整数 intVal重复左移,并调用showInt 进行显示。
;;subr.asm C++调用的ASM函数 C++测试文件13.3.3_subr.cpp
INCLUDE Irvine32.inc;外部C++函数:
askForInteger PROTO C
showInt PROTO C, value:SDWORD, outWidth:DWORD
newLine PROTO COUT_WIDTH = 8
ENDING_POWER = 10.data
intVal DWORD ?.code
;---------------------------------------------------------------
SetTextOutColor PROC C,color:DWORD
;
;设置文本颜色,并清除控制台窗口。
;调用Irvine32库函数。
;----------------------------------------------------------------mov eax, colorcall SetTextColorcall Clrscrret
SetTextOutColor ENDP;---------------------------------------------------------------
DisplayTable PROC C
;
;输入一个整数n并显示范围n* 2的1次方~n*2的10次方的乘法表。
;----------------------------------------------------------------INVOKE askForInteger ;调用c++函数mov intVal, eax ;保存整数mov ecx, ENDING_POWER ;循环计数器
L1: push ecx ;保存循环计数器shl intVal, 1 ;乘以2INVOKE showInt, intVal, OUT_WIDTHcall Crlf ;输出CR/LFpop ecx ;恢复循环计数器loop L1ret
DisplayTable ENDP
END
在 DisplayTable 过程中,必须在调用showInt和newLine之前将ECX人栈,并在调用后将 ECX出栈,这是因为VisualC++函数不会保存和恢复通用寄存器。函数 askForIntegel用 EAX寄存器返回结果。
DisplayTable 在调用C++函数时不一定要用INVOKE。PUSH 和CALL指令也能得到同样的结果。对showInt的调用如下所示:
push OUT_WIDTH ;最后一个参数首先入栈
push intVal
call showInt ;调用函数
add esp, 8 ;清除堆栈
必须遵守C语言调用规范,其参数按照逆序入栈,且主调方负责在调用后从堆栈移除实参。
2.C++测试库
下面查看启动程序的C++模块。其入口为main(),保证执行所需 C++语言的初始化代码。它包含了外部汇编过程和三个输出函数的原型:
//13.3.3_subr.cpp 演示C++程序和外部汇编模块之间的函数调用
#include <iostream>
#include <iomanip>using namespace std;
extern "C" {//外部ASM过程:void DisplayTable();void SetTextOutColor(unsigned color);//局部c++函数:int askForInteger();void showInt(int value, int width);
}
//程序入口
int main()
{SetTextOutColor(0x14); //蓝底红字。DisplayTable(); //调用ASM过程return 0;
}
//提示用户输入一个整数
int askForInteger()
{int n;std::cout << "Enter an integer between 1 and 90,000:";std::cin >> n;return n;
}//按物定宽度显示一个有符号整数
void showInt(int value, int width)
{std::cout << setw(width) << value;
}
生成项目 将C++和汇编模块添加到VisualStudio项目,并在Project菜单中选择Build Solution.
程序输出当用户输人为90000时,乘法表程序产生的输出如下:
3.Visual Studio 项目属性
如果使用 Visual Studio生成集成了C++和汇编代码的程序,并且调用Irvine32链接库,就需要修改某些项目设置。以MultiplicationTable程序为例。在Proiect菜单中选择Properties,在窗口左边的Configuration Properties条目下,选择Linker。在右边面板的 Additional Library Directories条目中输入c:\lrvine。示例如图 13-5 所示。点击 OK关闭Project Property Pages窗口。现在 Visual Studio 就可以找到 Irvine32 链接库了。
上述过程已在 Visual Studio 2012中测试通过,但可能会有变更。请查阅我们的网站(www.asmirvine.com)获取更新。
图13-5指定Irvine32.lib的位置
13.3.4 调用C库函数
C语言有标准函数集合,被称为标准C库(StandardCLibrary)。同样的函数还可以用于 C++程序,因此,也可用于与C和C++程序连接的汇编模块。汇编模块调用C函数时就必须包含函数的原型。一般通过访问C++编译器的帮助系统可以找到C函数原型。程序调用 C函数时,需要先将C函数原型转换为汇编语言原型。
printf函数 下面是printf函数的 C/C++语言原型,第一个参数为字符指针,其后跟了一组可变数量的参数:
int printf (
const char *format [,argument]...
);
(C/C++编译器的帮助库可以查阅到printf函数文档。)汇编语言中与之等效的函数原型将char*改为 PTR BYTE,将可变长度参数列表的类型改为VARARG:
printf PROTO c pString:PTR BYTE, args:VARARG
另一个有用的函数是scanf,用于从标准输入(键盘)接收字符、数字和字符串,并将输人数值分配给变量:
scanf PROTO C format:PTR BYTE, args:VARARG
1.用 printf 函数显示格式化实数
编写汇编函数格式化并显示浮点数不是一件容易的事。与其由程序员自行编码,还不如利用 C 库的 printf函数。需要创建C或C++的启动模块,并将其与汇编代码链接。下面给出了用Visual C++.NET 创建这种程序的过程:
1)用Visual C++创建一个Win32 控制台程序。创建文件main.cpp,并插人函数 main,该函数调用了asmMain:
extern "C" void asmMain()
int main()
{asmMain();return 0;
}
2)在main.cpp 所在的文件夹中,创建一个汇编模块asmMain.asm。该模块包含过程asmMain,并声明使用 C调用规范:
;asmMain.asm
.386
.model flat,stdcall
.stack 2000
.code
asmMain PROC Cret
asmMain ENDP
END
3)汇编asmMain.asm(但不进行链接),生成asmMain.obj。
4)将asmMain.obj添加到 C++项目。
5)构建并运行项目。如果修改了asmMain.asm,则在运行前,需要再一次汇编和构建项目。
一旦程序正确建立,就可以向asmMainasm添加代码来调用C/C++函数。
显示双精度数值 下面是asmMain中的汇编代码,它通过调用printf输出了一个类型为REAL8 的数值:
.data
double1 REAL8 1234567.890123
formatStr BYTE "%.3f", 0dh, 0ah, 0
.codeINVOKE printf, ADDR formatStr, double1
相应的输出如下:
这里,传递给printf的格式化字符串与C++中的略有不同:不是插入转义字符,如,而是必须插人ASCII字符(0dh,0ah)。
传递给 printf的浮点参数应声明为REAL8类型。不过传递的数值也可能是REAL4类型,这需要相当的编程技巧。若想了解C++编译器是如何工作的,可以声明一个float 类型的变量,并将其传递给 printf。编译程序,并用调试器跟踪该程序的反汇编代码。
多参数 printf函数接收可变数量的参数,因此很容易在一次函数调用中对两个数进行格式化并显示它们:
TAB=9
.data
formatTwo BYTE "%.2f",TAB,"%.3f",0dh,0ah,0
val1 REAL8 456.789
val2 REAL8 864.231
.codeINVOKE printf, ADDR formatTwo, val1, val2
相应的输出如下:
(参见本书Examplesch13\VisualCPP文件夹内的项目Printf_Example。)
2.用 scanf 函数输入实数
调用scanf可以从用户输入浮点数。SmallWin.inc(包括在Irvine32.inc 内)定义的函数原型如下所示:
scanf PROTO C,format:PTR BYTE, args:VARARG
传递给它的参数包括:格式化字符串的偏移量,一个或多个 REAL4、REAL8类型变量的偏移量(这些变量存放了用户输入的数值)。调用示例如下:
.data
strSingle BYTE "%f", 0
strDouble BYTE "%lf", 0
single1 REAL4 ?
doublel REAL8 ?
.code
INVOKE scanf, ADDR strSingle, ADDR single1
INVOKE scanf, ADDR strDouble, ADDR double1
必须从C或C++启动程序中调用汇编语言代码。
完整代码测试笔记
;asmMain.asm C++测试文件13.3.4_main.cpp INCLUDE Irvine32.inc
includelib legacy_stdio_definitions.lib ;;这个很重要,否则会报错:LNK2019 无法解析的外部符号_scanfprintSingle PROTO C,aSingle:REAL4, precision:DWORD.code
asmMain PROC C;---------- 测试 printf 函数 --------------
; 调用printf时,不要使用REAL4类型的变量。
TAB=9
.data
double1 REAL8 1234567.890123
formatStr BYTE "%.3f", 0dh, 0ah, 0
formatTwo BYTE "%.2f",TAB,"%.3f",0dh,0ah,0
val1 REAL8 456.789
val2 REAL8 864.231.codeINVOKE printf, ADDR formatStr, double1INVOKE printf, ADDR formatTwo, val1, val2;--------- 测试 scanf 函数 -------------
.datastrSingle BYTE "%f",0strDouble BYTE "%lf",0float1 REAL4 1234.567double2 REAL8 1234567.890123
.code; 输入一个float型和double型数据:INVOKE scanf, ADDR strSingle, ADDR float1INVOKE scanf, ADDR strDouble, ADDR double2;输出用户输入的值INVOKE printf, ADDR formatStr, double2
;--------------------------------------------------------
; 向printf传递一个单精度值很棘手,因为它期望参数是double。以下代码模拟了Visual C++生成的代码。
;在你阅读第17章之前,这可能没有多大意义。.data
valStr BYTE "float1 = %.3f",0dh,0ah,0
.codefld float1 ; 加载float1到浮点数栈sub esp,8 ; 保留运行时堆栈空间fstp qword ptr [esp] ; 将运行时堆栈设置为doublepush OFFSET valStrcall printfadd esp,12;----------------------------------------------------------
; 调用我们自己的C函数进行单精度打印。传递数字和所需的精度。INVOKE printSingle, float1, 3call Crlfret
asmMain ENDPEND
编译报错:
这里必须加上这个库includelib legacy_stdio_definitions.lib
重新编译运行:
13.3.5 目录表程序
现在编写一个简短的程序,清除屏幕,显示当前磁盘目录,并请求用户输人文件名。(程序员可能希望扩展该程序,以打开并显示被选中文件。
C++根模块 C++模块只有一个对asm_main 的调用,因此可以将其称为根模块(stubmodule):
//main.cpp
//根模块:启动汇编程序
extern "C" void asm_main(); //asm启动过程
void main()
{asm_main();
}
ASM模块 汇编语言模块包括了函数原型、若干字符串和一个fileName变量。模块两次调用 system 函数,向其传递“cls”和“dir”命令。然后调用 printf,显示请求文件名的提示行,再调用scanf,使用户输入文件名。程序不调用Irvine32库中的任何函数,因此可以将MODEL伪指令设置为C语言规范:
;13.3.5_asmMain.asm C++测试文件13.3.5_main.cpp
;从C++启动的ASM程序 .586
.model flat, C
includelib legacy_stdio_definitions.lib ;;VS2019编译环境——这个很重要,否则会报错:LNK2019 无法解析的外部符号_scanf
;标准C库函数:
system PROTO, pCommand:PTR BYTE
printf PROTO, pString:PTR BYTE, args:VARARG
scanf PROTO, pFormat:PTR BYTE, pBuffer:PTR BYTE, args:VARARG
fopen PROTO, mode:PTR BYTE, filename:PTR BYTE
fclose PROTO, pFile:DWORD
BUFFER_SIZE = 5000.data
str1 BYTE "cls", 0
str2 BYTE "dir/w", 0
str3 BYTE "Enter the name of a file:", 0
str4 BYTE "%s", 0
str5 BYTE "cannot open file", 0dh, 0ah,0
str6 BYTE "The file has been opened", 0dh, 0ah, 0
modeStr BYTE "r", 0fileName BYTE 60 DUP(0)
pBuf DWORD ?
pFile DWORD ?.code
asm_main PROC C;清除屏幕,显示磁盘目录INVOKE system, ADDR str1INVOKE system, ADDR str2;请求文件名INVOKE printf, ADDR str3INVOKE scanf, ADDR str4, ADDR filename;尝试打开文件INVOKE fopen, ADDR fileName, ADDR modeStrmov pFile, eax.IF eax == 0 ;不能打开文件?INVOKE printf, ADDR str5jmp quit.ELSEINVOKE printf, ADDR str6.ENDIF;关闭文件INVOKE fclose, pFile
quit:ret ;返回C++主程序
asm_main ENDP
END
运行调试:
函数scanf需要两个参数:第一个是格式化字符串(“%s”)的指针,第二个是输人字符串变量(fileName)的指针。因为互联网上有丰富的文档,因此这里不再浪费时间来解释标准C函数。一个很好的参考文献是1988 年Prentice Hall 出版的《The C ProgrammingLanguage》第二版,其作者是Brian W.Kernighan 和 Dennis M.Ritchie。
13.3.6 本节回顾
1.若函数被汇编模块调用,则函数定义中必须包括哪两个C++关键字?
答:必须使用关键字extern和"C"。
2.什么情况下Irvine32库使用的调用规范与C和C++语言使用的调用规范不兼容?
答:Irvine32链接库使用STDCALL,它与C和C++使用的C调用规范不同,其重点差异是函数调用后清除堆栈的方法。
3.C++函数通常怎样返回浮点数?
答:通常在函数返回前,浮点数会被压入处理器的浮点堆栈。
4.Microsoft Visual C++函数怎样返回short int 类型的数值?
答:用AX寄存器返回。
13.4 本章小结
若要对某些高级语言编写的大型应用程序中的指定部分进行优化,那么汇编语言堪称是完美的工具。同时,汇编语言也是为特定硬件定制一些程序的好工具。选择以下两种方法之一可以实现这些技术:
●在高级语言代码中编写内嵌汇编代码。
●把汇编程序链接到高级语言代码。
两种方法都有其有优点和局限性。本章对这两种方法都进行了介绍。
语言使用的命名规范是指段和模块的命名方式,也就是与变量和过程命名相关的规则或特性。程序使用的内存模式决定了调用和引用是近(同一段内)还是远(不同段间)。
从其他语言程序中调用汇编过程时,在两种语言代码中都用到的标识符必须是兼容的。在汇编过程中使用的段名也要与主调程序兼容。过程编写者使用的高级语言调用规范决定了如何接收参数。调用规范影响下列情形:是由被调过程恢复堆栈指针,还是由主调程序恢复堆栈指针。
在Visual C++ 中,伪指令__asm 用于在 C++源程序中编写内嵌汇编代码。本章的文件加密程序演示了内嵌汇编语言。
本章展示了怎样将汇编过程链接到运行于32位保护模式的Microsoft Visual C++程序。调用标准C(C++)库函数时,需用c或C++创建含有main()函数的根程序。main(开始时,编译器的运行时链接库自动初始化。在 main()中可以调用汇编模块的启动过程。汇编语言模块可以调用标准C库中的所有函数。
过程IndexOf用汇编语言编写,且被Visual C++程序调用。本章还查看了Microsoft C++编译器生成的汇编源文件,对编译器如何优化代码有了更清楚的了解。