3.7.2 直接调用C函数
在计算机的发展的过程中,C语言和UNIX操作系统有着不可替代的作用。因此操作系统的系统调用、汇编语言和C语言函数调用规则几个技术是密切相关的。
在X86的32位系统时代,C语言一般默认的是用栈传递参数并用AX寄存器返回结果,称为cdecl调用约定。Go语言函数和cdecl调用约定非常相似,它们都是以栈来传递参数并且返回地址和BP寄存器的布局都是类似的。但是Go语言函数将返回值也通过栈返回,因此Go语言函数可以支持多个返回值。我们可以将Go语言函数看作是没有返回值的C语言函数,同时将Go语言函数中的返回值挪到C语言函数参数的尾部,这样栈不仅仅用于传入参数也用于返回多个结果。
在X64时代,AMD架构增加了8个通用寄存器,为了提高效率C语言也默认改用寄存器来传递参数。在X64系统,默认有System V AMD64 ABI和Microsoft x64两种C语言函数调用规范。其中System V的规范适用于Linux、FreeBSD、macOS等诸多类UNIX系统,而Windows则是用自己特有的调用规范。
在理解了C语言函数的调用规范之后,汇编代码就可以绕过CGO技术直接调用C语言函数。为了便于演示,我们先用C语言构造一个简单的加法函数myadd:
#include <stdint.h>
int64_t myadd(int64_t a, int64_t b) {
return a+b;
}
然后我们需要实现一个asmCallCAdd函数:
func asmCallCAdd(cfun uintptr, a, b int64) int64
因为Go汇编语言和CGO特性不能同时在一个包中使用(因为CGO会调用gcc,而gcc会将Go汇编语言当做普通的汇编程序处理,从而导致错误),我们通过一个参数传入C语言myadd函数的地址。asmCallCAdd函数的其余参数和C语言myadd函数的参数保持一致。
我们只实现System V AMD64 ABI规范的版本。在System V版本中,寄存器可以最多传递六个参数,分别对应DI、SI、DX、CX、R8和R9六个寄存器(如果是浮点数则需要通过XMM寄存器传送),返回值依然通过AX返回。通过对比系统调用的规范可以发现,系统调用的第四个参数是用R10寄存器传递,而C语言函数的第四个参数是用CX传递。
下面是System V AMD64 ABI规范的asmCallCAdd函数的实现:
// System V AMD64 ABI
// func asmCallCAdd(cfun uintptr, a, b int64) int64
TEXT ·asmCallCAdd(SB), NOSPLIT, $0
MOVQ cfun+0(FP), AX // cfun
MOVQ a+8(FP), DI // a
MOVQ b+16(FP), SI // b
CALL AX
MOVQ AX, ret+24(FP)
RET
首先是将第一个参数表示的C函数地址保存到AX寄存器便于后续调用。然后分别将第二和第三个参数加载到DI和SI寄存器。然后CALL指令通过AX中保持的C语言函数地址调用C函数。最后从AX寄存器获取C函数的返回值,并通过asmCallCAdd函数返回。
Win64环境的C语言调用规范类似。不过Win64规范中只有CX、DX、R8和R9四个寄存器传递参数(如果是浮点数则需要通过XMM寄存器传送),返回值依然通过AX返回。虽然是可以通过寄存器传输参数,但是调用这依然要为前四个参数准备栈空间。需要注意的是,Windows x64的系统调用和C语言函数可能是采用相同的调用规则。因为没有Windows测试环境,我们这里就不提供了Windows版本的代码实现了,Windows用户可以自己尝试实现类似功能。
然后我们就可以使用asmCallCAdd函数直接调用C函数了:
/*
#include <stdint.h>
int64_t myadd(int64_t a, int64_t b) {
return a+b;
}
*/
import "C"
import (
asmpkg "path/to/asm"
)
func main() {
if runtime.GOOS != "windows" {
println(asmpkg.asmCallCAdd(
uintptr(unsafe.Pointer(C.myadd)),
123, 456,
))
}
}
在上面的代码中,通过C.myadd
获取C函数的地址,然后转换为合适的类型再传人asmCallCAdd函数。在这个例子中,汇编函数假设调用的C语言函数需要的栈很小,可以直接复用Go函数中多余的空间。如果C语言函数可能需要较大的栈,可以尝试像CGO那样切换到系统线程的栈上运行。
学员评价