8. 制作与多个编译器及平台兼容的函数库
有若干兼容性问题需要注意,如果你希望制作与多个编译器、多个编程语言及多个操作系统兼容的函数库。要处理的最重要的兼容性问题有:
- 名字重整
- 调用惯例
- 目标文件格式
这些可移植问题最容易的解决方案是以一个高级语言,比如C++,制作代码,使用固有函数或内联汇编制作任何必须的低级构造。然后使用不同的编译器对不同的平台编译这个代码。。注意,不是所有的C++编译器都支持固有函数或内联汇编,并且语法可能不同。如果汇编语言编程是必须或期望的,那么有各种方法来克服不同x86平台间的兼容性问题。这些方法在以下段落讨论。
8.1. 支持多种名字重整方案
处理编译器特定的名字重整方案问题最容易的方法是,使用extern ”C”指示关闭名字重整,如第23页所述。
Extern “C”指示不能用于类成员函数、重载函数与操作符。可以通过以制作一个调用具有非重整名汇编函数的重整名内联函数,来解决这个问题:
// Example 8.1. Avoid name mangling of overloaded functions in C++
// Prototypes for unmangled assembly functions:
extern "C" double power_d (double x, double n);
extern "C" double power_i (double x, int n);
// Wrap these into overloaded functions:
inline double power (double x, double n) {return power_d(x, n);
inline double power (double x, int n) {return power_i(x, n);
编译器将只是将对重整名函数的调用替换为对合适的非重整名汇编函数的调用,没有额外的代码。相同的方法可用于类成员函数,如第40页所述。
不过,在某些情形里,保留名字重整是期望的。即是因为它使得C++代码更简单,也是因为重整名包含了关于调用惯例以及其他兼容性问题的信息。
一个汇编函数,可以通过给予多个公开名,变得与多个名字重整方案兼容。回到第24页的例子4.1c,我们可以下面的方式对多个编译器添加重整名:
; Example 8.2. (Example 4.1c rewritten)
; Function with multiple mangled names (32-bit mode)
; double sinxpnx (double x, int n) {return sin(x) + n*x;}
ALIGN 4
_sinxpnx PROC NEAR ; extern "C" name
; Make public names for each name mangling scheme:
?sinxpnx@@YANNH@Z LABEL NEAR ; Microsoft compiler
@sinxpnx$qdi LABEL NEAR ; Borland compiler
_Z7sinxpnxdi LABEL NEAR ; Gnu compiler for Linux
__Z7sinxpnxdi LABEL NEAR ; Gnu compiler for Windows and Mac OS
PUBLIC ?sinxpnx@@YANNH@Z, @sinxpnx$qdi, _Z7sinxpnxdi, __Z7sinxpnxdi
; parameter x = [ESP+4]
; parameter n = [ESP+12]
; return value = ST(0)
fild dword ptr [esp+12] ; n
fld qword ptr [esp+4] ; x
fmul st(1), st(0) ; n*x
fsin ; sin(x)
fadd ; sin(x) + n*x
ret ; return value is in st(0)
_sinxpnx ENDP
例子8.2能与32位Windows与32位Linux中大多数编译器一起工作,因为调用惯例是相同的。一个函数可以有多个公开名,链接器将只是查找与C++文件中调用匹配的名字。但一个函数调用不能有多个外部名。
不同编译器名字重整的语法在手册5《不同编译器与操作系统调用惯例》中上描述。手动应用这个语法是一项困难的工作。使用合适的编译器编译C++函数,产生每个重整名,要容易、安全得多。大多数编译器的命令行版本是免费的,或者作为试用版本。
Windows的Intel、Digtial Mars与Codeplay编译器与Microsoft名字重整方案兼容。Linux的Intel编译器与Gnu名字重整方案兼容。Gnu编译器2.x及更早的版本有不同的、我没有包含在例子8.2里的名字重整方案。Watcom编译器的重整名包含仅被Watcom汇编器允许的特殊字符。
8.2. 在32位模式中支持多个调用惯例
在32位Windows中成员函数不总是有相同的调用惯例。Mcirosoft兼容编译器使用__thiscall惯例,this在寄存器ecx中,而Borland与Gnu编译器使用__cdecl惯例,this在栈上。一个解决方案是使用第42页例子7.1a所示的友元函数。另一个可能是制作具有多个入口的函数。下面的例子是第42页例子7.1a的重写,有两个入口对应两个不同的调用惯例:
; Example 8.3a (Example 7.1a with two entries)
; Member function, 32-bit mode
; int MyList::Sum()
; Define structure corresponding to class MyList:
MyList STRUC
length_ DD ?
buffer DD 100 DUP (?)
MyList ENDS
_MyList_Sum ROC NEAR ; for extern "C" friend function
; Make mangled names for compilers with __cdecl convention:
@MyList@Sum$qv LABEL NEAR ; Borland compiler
_ZN6MyList3SumEv LABEL NEAR ; Gnu comp. for Linux
__ZN6MyList3SumEv LABEL NEAR ; Gnu comp. for Windows and Mac OS
PUBLIC @MyList@Sum$qv, _ZN6MyList3SumEv, __ZN6MyList3SumEv
; Move 'this' from the stack to register ecx:
mov ecx, [esp+4]
; Make mangled names for compilers with __thiscall convention:
?Sum@MyList@@QAEHXZ LABEL NEAR ; Microsoft compiler
PUBLIC ?Sum@MyList@@QAEHXZ
assume ecx: ptr MyList ; ecx points to structure MyList
xor eax, eax ; sum = 0
xor edx, edx ; Loop index i = 0
cmp [ecx].length_, 0 ; this->length
je L9 ; Skip if length = 0
L1: add eax, [ecx].buffer[edx*4] ; sum += buffer[i]
add edx, 1 ; i++
cmp edx, [ecx].length_ ; while (i < length)
jb L1 ; Loop
L9: ret ; Return value is in eax
_MyList_Sum ENDP ; End of int MyList::Sum()
assume ecx: nothing ; ecx no longer points to anything
名字重整方案中的差异,在这里实际上是优势,因为它使得链接器将调用引导到对应正确调用惯例的入口。
如果成员函数有更多参数,这个方法变得更复杂。考虑第33页的函数void MyList::AttItem (int item)。惯例__thiscall使参数this在ecx里,参数item在栈上[esp+4]处,且要求该函数清理栈。惯例__cdecl使两个参数都在栈上,this在[esp+4]处,item在[esp+8]处,且调用者清理栈。使用两个函数入口的解决方案要求一个跳转:
; Example 8.3b
; void MyList::AttItem(int item);
_MyList_AttItem PROC NEAR ; for extern "C" friend function
; Make mangled names for compilers with __cdecl convention:
@MyList@AttItem$qi LABEL NEAR ; Borland compiler
_ZN6MyList7AttItemEi LABEL NEAR ; Gnu comp. for Linux
__ZN6MyList7AttItemEi LABEL NEAR ; Gnu comp. for Windows and Mac OS
PUBLIC @MyList@AttItem$qi, _ZN6MyList7AttItemEi, __ZN6MyList7AttItemEi
; Move parameters into registers:
mov ecx, [esp+4] ; ecx = this
mov edx, [esp+8] ; edx = item
jmp L0 ; jump into common section
; Make mangled names for compilers with __thiscall convention:
?AttItem@MyList@@QAEXH@Z LABEL NEAR; Microsoft compiler
PUBLIC ?AttItem@MyList@@QAEXH@Z
pop eax ; Remove return address from stack
pop edx ; Get parameter 'item' from stack
push eax ; Put return address back on stack
L0: ; common section where parameters are in registers
; ecx = this, edx = item
assume ecx: ptr MyList ; ecx points to structure MyList
mov eax, [ecx].length_ ; eax = this->length
cmp eax, 100 ; Check if too high
jnb L9 ; List is full. Exit
mov [ecx].buffer[eax*4],edx ; buffer[length] = item
add eax, 1 ; length++
mov [ecx].length_, eax
L9: ret
_MyList_AttItem ENDP ; End of MyList::AttItem
assume ecx: nothing ; ecx no longer points to anything
在例子8.3b中,这两个函数入口每个都将所有的参数载入寄存器,然后跳转到一个不需要从栈读参数的公共部分。__thiscall入口必须在这个公共部分之前从栈消除参数。
当我们希望在32位Windows中同时拥有一个函数库的静态与动态链接版本时,出现另一个兼容性问题。静态链接库缺省使用__cdecl惯例,而动态链接库缺省使用__stdcall惯例。对C++程序,静态链接库是最高效的解决方案,但对其他几个编程语言,动态链接库是需要的。
这个问题的一个解决方案是,将这两个库声明为__cdecl或__stdcall惯例。另一个解决方案是制作有两个入口的函数。
下面的例子展示了来自例子8.2,使用__cdecl与__stdcall惯例,有两个入口的函数。这两个惯例的参数都在栈上。差别是,在__cdecl惯例里栈由调用者清理,在__stdcall惯例里由被调用函数清理。
; Example 8.4a (Example 8.2 with __stdcall and __cdecl entries)
; Function with entries for __stdcall and __cdecl (32-bit Windows):
ALIGN 4
; __stdcall entry:
; extern "C" double __stdcall sinxpnx (double x, int n);
_sinxpnx@12 PROC NEAR
; Get all parameters into registers
fild dword ptr [esp+12] ; n
fld qword ptr [esp+4] ; x
; Remove parameters from stack:
pop eax ; Pop return address
add esp, 12 ; remove 12 bytes of parameters
push eax ; Put return address back on stack
jmp L0
; __cdecl entry:
; extern "C" double __cdecl sinxpnx(double x, int n);
_sinxpnx LABEL NEAR
PUBLIC _sinxpnx
; Get all parameters into registers
fild dword ptr [esp+12] ; n
fld qword ptr [esp+4] ; x
; Don't remove parameters from the stack. This is done by caller
L0: ; Common entry with parameters all in registers
; parameter x = st(0)
; parameter n = st(1)
fmul st(1), st(0) ; n*x
fsin ; sin(x)
fadd ; sin(x) + n*x
ret ; return value is in st(0)
_sinxpnx@12 ENDP
在函数prolog中,而不是epilog中,从栈移除参数的方法,无可否认是笨拙的。一个更高效的解决方案是使用条件汇编:
; Example 8.4b
; Function with versions for __stdcall and __cdecl (32-bit Windows)
; Choose function prolog according to calling convention:
IFDEF STDCALL_ ; If STDCALL_ is defined
_sinxpnx@12 PROC NEAR ; extern "C" __stdcall function name
ELSE
_sinxpnx PROC NEAR ; extern "C" __cdecl function name
ENDIF
; Function body common to both calling conventions:
fild dword ptr [esp+12] ; n
fld qword ptr [esp+4] ; x
fmul st(1), st(0) ; n*x
fsin ; sin(x)
fadd ; sin(x) + n*x
; Choose function epilog according to calling convention:
IFDEF STDCALL_ ; If STDCALL_ is defined
ret 12 ; Clean up stack if __stdcall
_sinxpnx@12 ENDP ; End of function
ELSE
ret ; Don't clean up stack if __cdecl
_sinxpnx ENDP ; End of function
ENDIF
这个方案要求你制作两个版本的目标文件,一个对静态链接库使用__cdecl调用惯例,一个对动态链接库使用__stdcall调用惯例。这个区分在汇编器的命令行上进行。在命令行上使用/DSTDCALL_定义由IFDEF检测的宏STDCALL_,汇编__stdcall版本。
8.3. 在64位模式中支持多个调用惯例
在64位系统中,调用惯例得到比32位系统更好的标准化。64位Windows仅有一个调用惯例,64位Linux与其他类Unix系统有一个调用惯例。不幸,这两个调用惯例相当不同。最重要的差异有:
- 在这两个系统中,函数参数在不同的寄存器中传递。
- 在64位Windows中,寄存器RSI,RDI与XMM6 – XMM15有被调用者保存状态,但在64位Linux中没有。
- 在64位Windows中,调用者必须在栈上保留一个32字节的“影子空间”,但在64位Linux中不用。
- 在64位Linux中,在栈指针以下,一个128字节的“红区”可用于储存,在64位Windows中不需要。
在64位Windows中使用Microsoft名字重整方案,在64位Linux中使用Gnu名字重整方案。
在任何调用前,这两个系统都要栈对齐到16,都要调用者清理栈。
在充分考虑这两个系统间的差异时,制作可以用在这两个系统中的函数是可能的。在Windows中,这个函数应该保存具有被调用者保存状态的寄存器,否则不管它们。这个函数不应该使用影子空间或红区。这个函数应该对任何它调用的函数保留一个影子空间。如果有任何整数参数,这个函数需要两个入口,来解决该参数在寄存器使用方面的差异。
让我们再次使用第23页的例子4.1,制作一个能在64位Windows及64位Linux上工作的实现。
; Example 8.5a (Example 4.1e/f combined).
; Support for both 64-bit Windows and 64-bit Linux
; double sinxpnx (double x, int n) {return sin(x) + n * x;}
EXTRN sin:near
ALIGN 8
; 64-bit Linux entry:
_Z7sinxpnxdi PROC NEAR ; Gnu name mangling
; Linux has n in edi, Windows has n in edx. Move it:
mov edx, edi
; 64-bit Windows entry:
?sinxpnx@@YANNH@Z LABEL NEAR ; Microsoft name mangling
PUBLIC ?sinxpnx@@YANNH@Z
; parameter x = xmm0
; parameter n = edx
; return value = xmm0
push rbx ; rbx must be saved
sub rsp, 48 ; space for x, shadow space f. sin, align
movapd [rsp+32], xmm0 ; save x across call to sin
mov ebx, edx ; save n across call to sin
call sin ; xmm0 = sin(xmm0)
cvtsi2sd xmm1, ebx ; convert n to double
mulsd xmm1, [rsp+32] ; n * x
addsd xmm0, xmm1 ; sin(x) + n * x
add rsp, 48 ; restore stack pointer
pop rbx ; restore rbx
ret ; return value is in xmm0
_Z7sinxpnxdi ENDP ; End of function
这里,我们不使用extern “C”声明,因为我们依赖不同的名字重整方案来区别Windows与Linux。使用两个项来解决参数传递中的差异。如果函数声明在x之前是n,即double sinxpnx (int n, double x);,那么Windows版本将使x在XMM1、n在ecx中,而Linux版本将仍然让x在XMM0、n在EDI中。这个函数在调用sin的过程中,将x保存到栈上,因为在64位Linux中没有被调用者保存状态的XMM寄存器。这个函数对sin的调用保留32字节的影子空间,尽管在Linux中这不需要。
8.4. 支持各种目标文件格式
另一個兼容性問題源自目标文件格式中的差异。
Borland、Digital Mars与16位Microsoft编译器对目标文件使用OMF格式。对32位Windows,Microsoft、Intel及Gnu编译器使用COFF格式,也称为PE32。32位Linux下的Gnu与Intel编译器优先选择ELF32格式。用于Mac OS X的Gnu与Intel编译器使用32与64位Mach-O格式。32位Codeplay编译器支持OMF、PE32与ELF32格式。所有用于64位Windows的编译器都使用COFF/PE32+格式,而用于64位Linux的编译器使用ELF64格式。
MASM汇编器可以产生OMF、COFF/PE32及COFF/PE32+格式目标文件,但不支持ELF格式。NASM汇编器支持OMF、COFF/PE32与ELF32格式。YASM汇编器支持OMF、COFF/PE32、ELF32/64、COFF/PE32+与MachO32/64格式。Gnu汇编器(Gas)支持ELF32/64与MachO32/64格式。
如果你有一个支持所有你需要的目标文件格式的汇编器,或者一个合适的目标文件转换程序,进行跨平台开发是可能的。这有助于制作工作在多个平台上的函数库。一个名为objconv的目标文件转换器与跨平台库管理器,可从www.agner.org/optimize获得。
Objconv应用程序可以改变目标文件中的函数名,以及转换到另一个目标文件格式。这消除了名字重整的需要。不使用名字重整,重写例子8.5:
; Example 8.5b.
; Support for both 64-bit Windows and 64-bit Unix systems.
; double sinxpnx (double x, int n) {return sin(x) + n * x;}
EXTRN sin:near
ALIGN 8
; 64-bit Linux entry:
Unix_sinxpnx PROC NEAR ; Linux, BSD, Mac entry
; Unix has n in edi, Windows has n in edx. Move it:
mov edx, edi
; 64-bit Windows entry:
Win_sinxpnx LABEL NEAR ; Microsoft entry
PUBLIC Win_sinxpnx
; parameter x = xmm0
; parameter n = edx
; return value = xmm0
push rbx ; rbx must be saved
sub rsp, 48 ; space for x, shadow space f. sin, align
movapd [rsp+32], xmm0 ; save x across call to sin
mov ebx, edx ; save n across call to sin
call sin ; xmm0 = sin(xmm0)
cvtsi2sd xmm1, ebx ; convert n to double
mulsd xmm1, [rsp+32] ; n * x
addsd xmm0, xmm1 ; sin(x) + n * x
add rsp, 48 ; restore stack pointer
pop rbx ; restore rbx
ret ; return value is in xmm0
Unix_sinxpnx ENDP ; End of function
现在,可以汇编这个函数,并以下面的命令转换到多个文件格式:
ml64 /c sinxpnx.asm
objconv -cof64 -np:Win_: sinxpnx.obj sinxpnx_win.obj
objconv -elf64 -np:Unix_: sinxpnx.obj sinxpnx_linux.o
objconv -mac64 -np:Unix_:_ sinxpnx.obj sinxpnx_mac.o
第一行使用Microsoft 64位汇编器ml64汇编这个代码,产生一个COFF目标文件。
第二行在目标文件中,去除函数名开头的Win_。结果是一个64位Windows的COFF目标文件,其中我们函数的Windows入口是extern "C" double sinxpnx(double x, int n)。在这个目标文件中,Unix入口的名字Unix_sinxpnx没有变,但没有使用。第三行将文件转换为64位Linux及BSD的ELF格式,在目标文件中去除函数名开头的Unix_。这使得这个函数的Unix入口成为sinxpnx,而未使用的Windows入口是Win_sinxpnx。第四行对MachO文件格式做相同的事,按Mac编译器的要求,在函数名上添加一个下划线前缀。
Objconv还可以构建及转换静态库文件(*.lib,*.a)。这使得在单个源平台上构建一个多平台函数库成为可能。
这个方法是一个使用例子是可在www.agner.org/optimize/获得的多平台函数库asmlib.zip。asmlib.zip包括一个使用目标文件转换器objconv制作库多个版本的makefile(参考第44页)。
关于目标文件格式的更多细节,可以在J.R. Levine的《Linkers and Loaders》(Morgan Kaufmann Publ. 2000)中找到。
8.5. 支持其他高级语言
如果你正在使用C++以外的其他高级语言,而编译器手册没有关于如何与汇编链接的信息,那么看一下手册是否有如何与C或C++模块链接的信息。从这个信息可能会找出与汇编链接的方法。
通常,最好使用与C++中的extern “C”以及__cdecl或__stdcall惯例兼容的、没有名字重整的简单函数。这将适用于大多数编译语言。在不同的语言里,数组与字符串的实现通常是不同的。
许多现代编程语言,比如C#与Visual Basic.NET不能链接到静态库。你必须制作动态链接库。链接到目标文件,Delphi Pascal可能会有问题——使用DLL更容易。
从Java调用汇编代码是相当复杂的。你必须把代码编译为一个DLL或共享对象,使用Java Native Interface(JNI)或Java Native Access(JNA)。