Go调用汇编和C
2021.07

3.1 Go调用汇编和C

只要不使用C的标准库函数,Go中是可以直接调用C和汇编语言的。其实道理很简单,Go的运行时库就是用C和汇编实现的,Go必须是能够调用到它们的。当然,会有一些额外的约束,这就是函数调用协议。

Go中调用汇编

假设我们做一个汇编版本的加法函数。首先GOPATH的src下新建一个add目录,然后在该目录加入add.go的文件,内容如下:

package add

func Add(a, b uint64) uint64 {
    return a+b
}

这个函数将两个uint64的数字相加,并返回结果。我们写一个简单的函数调用它,内容如下:

package main

import (
  "fmt"
  "add"
)

func main() {
     fmt.Println(add.Add(2, 15))
}

可以看到输出了结果为17。好的,接下来让我们删除Add函数的实现,只留下定义部分:

package add

func Add(a, b uint64) uint64

然后在add.go同一目录中建立一个add_amd64.s的文件(假设你使用的是64位系统),内容如下:

TEXT    ·Add+0(SB),$0-24
MOVQ    a+0(FP),BX
MOVQ    b+8(FP),BP
ADDQ    BP,BX
MOVQ    BX,res+16(FP)
RET     ,

虽然汇编是相当难理解的,但我相信读懂上面这段不会有困难。前两条MOVQ指令分别将第一个参数放到寄存器BX,第二个参数放到寄存器BP,然后ADDQ指令将两者相加后,最后的MOVQ和RET指令返回结果。

现在,再次运行前面的main函数,它将使用自定义的汇编版本函数,可以看到成功的输出了结果17。从这个例子中可以看出Go是可以直接调用汇编实现的函数的。大多时候不必要你去写汇编,即使是研究Go的内部实现,能读懂汇编已经很足够了。

也许你真的觉得在Go中写汇编很酷,但是不要忽视了这些忠告:

汇编很难编写,特别是很难写好。通常编译器会比你写出更快的代码。

汇编仅能运行在一个平台上。在这个例子中,代码仅能运行在 amd64 上。这个问题有一个解决方案是给 Go 对于 x86 和 不同版本的代码分别写一套代码,文件名相应的以386.s和arm.s结尾。

汇编让你和底层绑定在一起,而标准的 Go 不会。例如,slice 的长度当前是 32 位整数。但是也不是不可能为长整型。当发生这些变化时,这些代码就被破坏了。

当前Go编译器不能将汇编编译为函数的内联,但是对于小的Go函数是可以的。因此使用汇编可能意味着让你的程序更慢。

有时需要汇编给你带来一些力量(不论是性能方面的原因,还是一些相当特殊的关于CPU的操作)。对于什么时候应该使用它,Go源码包括了若干相当好的例子(可以看看 crypto 和 math)。由于它非常容易实践,所以这绝对是个学习汇编的好途径。

Go中调用C

接下来,我们继续尝试在Go中调用C,跟调用汇编的过程很类似。首先删掉前面的add_amd64.s文件,并确保add.go文件中只是给出了Add函数的声明部分:

package add

func Add(a, b uint64) uint64

然后在add.go同目录中,新建一个add.c文件,内容如下:

#include "runtime.h"

void ·Add(uint64 a, uint64 b, uint64 ret) {
    ret = a + b;
    FLUSH(&ret);
}

编译该包,运行前面的测试函数:

go install add

会发现输出结果为17,说明Go中成功地调用到了C写的函数。

要注意的是不管是C或是汇编实现的函数,其函数名都是以·开头的。还有,C文件中需要包含runtime.h头文件。这个原因在该文件中有说明:

Go用了特殊寄存器来存放像全局的struct G和struct M。包含这个头文件可以让所有链接到Go的C文件都知道这一点,这样编译器可以避免使用这些特定的寄存器作其它用途。

让我们仔细看一下这个C实现的函数。可以看到函数的返回值为空,而参数多了一个,第三个参数实际上被作为了返回值使用。其中FLUSH是在pkg/runtime/runtime.h中定义为USED(x),这个定义是Go的C编译器自带的primitive,作用是抑制编译器优化掉对*x的赋值的。如果你很好奇USED是怎样定义的,可以去$GOROOT/include/libc.h文件里去找找。

被调函数中对参数ret的修改居然返回到了调用函数,这个看起来似乎不可理解,不过早期的C编译器确实是可以这么做的。

函数调用时的内存布局

Go中使用的C编译器其实是plan9的C编译器,和我们平时理解的gcc等会有一些区别。我们将上面的add.c汇编一下:

go tool 6c -I $GOROOT/src/pkg/runtime -S add.c

生成的汇编代码大概是这个样子的:

"".Add t=1 size=16 value=0 args=0x18 locals=0
000000 00000 (add.c:3)    TEXT    "".Add+0(SB),4,$0-24
000000 00000 (add.c:3)    NOP    ,
000000 00000 (add.c:3)    NOP    ,
000000 00000 (add.c:3)    FUNCDATA    $2,gcargs.0<>+0(SB)
000000 00000 (add.c:3)    FUNCDATA    $3,gclocals.1<>+0(SB)
000000 00000 (add.c:4)    MOVQ    a+8(FP),AX
0x0005 00005 (add.c:4)    ADDQ    b+16(FP),AX
0x000a 00010 (add.c:4)    MOVQ    AX,c+24(FP)
0x000f 00015 (add.c:5)    RET    ,
000000 48 8b 44 24 08 48 03 44 24 10 48 89 44 24 18 c3  H.D$.H.D$.H.D$..

这是Go使用的汇编代码,是一种类似plan9的汇编代码。类似a+8(FP)这种表示的含义是“变量名+偏移(寄存器)”。其中FP是帧寄存器,它是一个伪寄存器,实际上是内存位置的一个引用,其实就是BP(栈基址寄存器)上移一个机器字长位置的内存地址。

函数调用之前,a+8(FP),b+16(FP)分别表示参数a和b,而参数3的位置被空着,在被调函数中,这个位置将用于存放返回值。此时的其内存布局如下所示:

参数3
参数2
参数1  <-SP 

进入被调函数之后,内存布局如下所示:

参数3
参数2
参数1  <-FP
保存PC <-SP
...
...

CALL指令会使得SP下移,SP位置的内存用于保存返回地址。帧寄存器FP此时位置在SP上面。在plan9汇编中,进入函数之后的前几条指令并没有出现push ebp; mov esp ebp这种模式。plan9函数调用协议中采用的是caller-save的模式,也就是由调用者负责保存寄存器。注意这和传统的C是不同的。传统C中是callee-save的模式,被调函数要负责保存它想使用的寄存器,在函数退出时恢复这些寄存器。

需要注意的是参数和返回值都是有对齐的。这里是按Structrnd对齐的,Structrnd在源代码中义为sizeof(uintptr)。