跨平台汇编程序设计


汇编的“跨平台”不是高级语言跨平台一样的概念,因为X86的汇编,无论如何要兼容ARM CPU都是做不到的。不过,由于x86/amd64的流行,在这两个硬件平台以及windows和各种linux软件平台之间,由于CPU指令一致性极高,而软件在多平台部署的要求也比较多,因此在这些软硬件平台上实现汇编的跨平台是软件跨平台部署的其中一个重要需求。而汇编的跨平台,即是要求同一份汇编源码,在不同平台上均可编译为所需的目标文件并连接到需要的工程中。

虽然x86/amd64的指令一致性非常高,但是要实现汇编的跨平台还是有相当的难度。其中最难的是兼容函数调用ABI。由于各软硬件平台上函数调用ABI差异极大,而汇编原则上是不提供函数ABI这个概念的,导致汇编函数的头尾在每个平台都需要分别编写。这样一来,在需要兼容的平台较多的情况下,汇编函数的头尾代码远远多于实际函数代码,给维护带来极大困难。而且,由于函数调用ABI的差异,各种函数参数和局部变量访问方式是不同的,导致实际函数代码部分又不能使用同一份代码,导致无法做到同一份代码在不同平台使用。

现在有汇编工具开始支持函数调用ABI。Masm32 SDK是其中支持得比较好的,但是只支持Win32平台。Nasm是跨平台支持较为完善的汇编程序,依托其开发的宏包nasmx实现了各种函数调用ABI的支持,但是其最大问题是函数参数不能以名来直接访问,在编写函数代码是仍然需要根据ABI约定访问参数,于是在不同平台代码就不能保持一致。除外,无论哪个宏包,都没有支持寄存器分配,这给32-64平台跨越增加了难度,因为二者寄存器数量不同,如果不能以变量名的方式访问寄存器,那么指令就必然不同。

针对这些问题我设计了一个算是支持跨平台的nasm宏包。该宏包的宗旨就是同一份汇编源码在不同平台实现相同功能。由于函数调用ABI在多平台的复杂性,而我个人精力有限,因此只支持了很少的我认为确实值得编写汇编函数的场景。大致上,该宏包的应用限制为:

1. x86/amd64汇编,nasm汇编器,nasmx-1.4宏包。

2. win32/win64/linux软件平台,gcc/borland c编译器。在Windows平台,gcc为MingW。原则上,VC应该可以使用编译出的obj文件,但未曾测试过。不支持OSX平台。

3. 目前只支持C调用约定。由于32位平台各种编译器的调用约定过于复杂,而32位平台正在逐渐退出历史舞台,因此这里只支持其中一致性较好的C调用约定。在64位平台,win64的vectorcall调用约定正在流行,但是因为细节资料不足,暂未支持。

4. 汇编函数实现的功能是确实值得使用汇编编写的。一般来说,这样的功能通常需要使用SSE/AVX指令,几乎不调用别的函数,即使调用函数也是几个汇编函数中间的内部调用,算法结构不算复杂,一般不需要使用太复杂的控制结构(如果控制结构很复杂,应当拆分成多个函数,并使用C/C++函数组合为最终功能),局部变量不多,一般没有数组局部变量,大块内存无论是作为源数据、输出缓冲区还是中间缓冲区,一般是上级函数分配好以指针方式传递过来。注意这里列出的这些条件,不是说只要违反了就不能用这个宏包,而是说如果有其中的一些情况,跨平台代码就可能会相对复杂。

宏包用法:

1. 包含头文件

%include "ASM/nasmxxFunc.ins"

由于很多文本编辑器把inc文件识别为pascal语法,我使用ins后缀标识汇编头文件,这样很容易修改软件配置正确识别ins文件为汇编文件。

2. 定义函数

procCF funcName [, arg1 [, arg2...] ]
[forceStackFrame]
[addUsedGR reg1[,reg2...]]
[addUsedXR reg1[,reg2...]]
[specialGR name1 reg1[,name2 reg2]]
[specialXR name1 reg1[,name2 reg2]]
defLocals <local1[,local2...]>|<none>
      ...;func codes
endpCF

函数定义的宏名为procCF,其第一个参数为函数名,后面是参数列表。函数定义结束的宏名为endpCF。在函数定义后面,在实际的函数代码之前,必须使用defLocals宏定义局部变量。如果没有局部变量,使用defLocals none。在defLocals之前,可以设置一些函数ABI的相关参数。forceStackFrame宏强制使用ebp/rbp生成栈框架。如果没有存储在内存局部变量、也没有需要保存的寄存器,宏包缺省是不生成栈框架的。在实现函数调用ABI过程中,宏包会自动记录使用了的寄存器(包括通用寄存器GR和SSE寄存器XR),如果某使用过的寄存器在当前ABI中是需要保存的,宏包将输出相应的寄存器保存恢复指令。如果未通过参数/变量定义使用了某些特殊寄存器,可用宏addUsedGR/addUsedXR把相应寄存器加到列表,宏包会根据当前ABI的约定决定是否保存恢复相应寄存器。一些特殊指令会隐含使用特殊寄存器(如maskmovdqu指令隐含使用edi/rdi寄存器),为方便代码编写可用宏specialGR/specialXR为相应寄存器定义一个名字,宏包会把相关寄存器加入使用列表并根据ABI约定保存和恢复。

函数参数和局部变量的定义格式为:

[regHint] type name [arrayDim]

regHint是寄存器分配信息,可没有。如有则为:gr/gr_f/xr/xr_f中的一个。gr表示给该变量尽可能分配一个通用寄存器,gr_f表示一定给该变量分配一个通用寄存器(如无法达成则报错)。xr/xr_f为相应的SSE寄存器提示。

type是变量类型,如dword/qword/int64_t/__m128等,nasmx支持的类型均支持(nasmx中可用sizeof()计算大小的名称)。

name是变量名。本宏包的特点是在函数代码中,可以用%$name直接服务变量,而不需要根据ABI约定在不同平台使用不同名称/语法访问变量。只有实现了这一点才能实现同一代码在不同平台编译。

arrayDim定义数组变量的大小。非数组变量不需要。注意,函数参数的数组大小被忽略,任何被定义为数组的参数其类型自动转换为void *。且宏包不支持多维数组。

宏包的最大特色是可直接使用名访问变量,可根据ABI情况分配寄存器变量。因此宏包使用的技巧就是在函数参数和局部变量定义中正确地设置寄存器分配信息。宏包对所有函数参数和局部变量进行两趟编译,第一趟分配所有的强制寄存器变量,第二次分配非强制寄存器变量。非强制寄存器变量将根据可用寄存器的数量来分配,如果寄存器数量不够,非强制寄存器变量将被存放在内存并用ebp/rbp索引,其名称被定义为相应的内存访问代码。因此,在程序中应尽量完整地定义所使用的变量,然后用名访问,尽量避免直接使用寄存器,因为直接使用寄存器可能导致平台不兼容问题。

宏包会尽量消除不同平台的函数参数传递,如在win64平台很多类型会以索引方式传递,如果代码中定义了该参数存放到寄存器,宏包会生成指令读取相应值到分配的寄存器中。如下面的函数定义:

procCF add, xr_f __m128 v1, xr_f __m128 v2
......

在函数代码中,均可直接使用%$v1和%$v2访问相应的sse寄存器,宏包在win32和win64平台会生成加载变量值到sse寄存器的指令,在linux64平台则会直接把%$v1和%$v2定义为传递参数的sse寄存器。在win64平台,栈传递的以索引方式传递的参数需要两次内存读取方可获得其值,如果代码定义其到寄存器,则宏包会生成两条指令读取相应值。注意如果一个参数按约定以通用寄存器传递,你可以定义其到sse寄存器中,宏包会分配额外sse寄存器并将通用寄存器的值传递过去,并释放相应的通用寄存器。但是,如果参数按约定以sse寄存器传递,宏包无法将其传递到通用寄存器(因为这种情况很少,因此宏包未予实现)。如果代码定义其到通用寄存器,则宏包会报错。

局部变量如果被分配到寄存器,宏包将不会给其保留内存空间。宏包不会给任何分配的寄存器变量预留内存保存区。宏包也不会使用任何参数传递空间或win64预留的参数寄存器保存区。

局部变量会被对齐到ABI约定的栈对齐编辑。例如,4字节变量在64位系统将占用16直接栈空间,而不管前后变量的大小情况。因此应尽量少使用内存局部变量。

宏包可在这里下载(提取码h533),其中还有用宏包实现双线性插值算法的代码可作参考。该代码在win32/win64平台测试通过,汇编代码编译为linux32/64通过,但未运行过。