通过 C 代码调用 Go

2,093 阅读4分钟
原文链接: zhuanlan.zhihu.com

我们知道在 Go 中可以通过 Cgo 来调用 C 代码的,那么反过来能不能用 C 来调用 Go 呢?答案是可以的,通过动态链接库的方式。

在谈论具体操作之前,我们先来讨论一下通过 C 来调用 Go 的使用场景。Go 相比于 C 的好处在于开发效率高,特别写网络。如果我们之前有一些老的系统已经用 C/C++ 写完了,这个时候需要打个 patch,而且这个对于性能也没有特别的要求,但是对时间有要求,那么这个时候使用 Go 来开发,然后通过 C 来调用也不失为一种好方法。

0. 动态链接

我们先来了解一下动态链接,以及静态链接。我们知道在 GNU 编译系统上一个 C 语言程序(比如 main.c )从代码到运行需要经过一下几步:预处理、编译、汇编、链接。使用 gcc 编译的时候加上 -v 参数可以看到这几个步骤(可能没有预处理步骤,因为现在很多编译器已经将预处理合并到了编译这一步里面)。其中各个步骤的作用如下:

  1. 预处理:将代码中的引用进行代码替换。main.c -> main.i 。
  2. 编译:将 main.i 翻译成一个汇编文件 main.s。
  3. 汇编:将 main.s 翻译成一个可重定位的目标文件 main.o。
  4. 链接:将 main.o 和其他依赖模块组合成一个可执行的目标文件。

静态链接将上面第 3 步生成的一组可重定位的目标文件生成一个完全链接的、可以加载和运行的可执行文件作为输出。这个过程中主要包括两步:符号解析和重定位。符号包括函数、变量等,符号解析就是将我们代码中引用别的模块中的符号和它的定义关联起来。重定位是为了保证程序运行的时候可以执行到正确的内存位置。通俗地说:静态链接就是多个模块的目标文件组合成一个可执行目标文件。

静态链接的坏处在于我们程序依赖的每个模块都需要整合进最终的可执行文件,如果有一些基础模块是大家的程序的共享那就意味着内存中存在多份,这无疑是一种浪费。另外,每次依赖模块的改变都需要我们的可执行文件重新进行一次编译链接,这是非常不合理的。举个例子,操作系统如果是完全静态链接的话,那么每一个操作系统补丁都意味着我们要重新编译一次操作系统代码,然而实际上并没有,因为补丁使用的是动态链接的方式。

动态链接的输入不再是目标文件 .o,而是动态链接库 .so。编译系统看到 .so 就明白了:“哦,这个函数地址运行的时候才能确定。”那么是如何确定的呢?拿 GNU 编译系统来说,使用的技术是延迟绑定(lazy binding)。延迟绑定的实现通过两个关键数据结构:全局偏移量表(Global Offset Table, GOT)和过程链接表(Procedure Linkage Table, PLT)。感兴趣的同学可以自行搜索,或者参考《深入理解计算机系统》的第七章 “链接”。

上面说了这么多,我们先来使用 C 语言构建一个自己的 so 文件。首先是头文件:legendtkl.h。

//legendtkl.h
int foo();
int bar();

下面是 .c 文件:legendtkl.c 。

#include "legendtkl.h"
#include <stdio.h>

int foo() {
    int a[1000] = {1,2,3};
    return a[0] + a[1] + a[2];
}

int bar() {
    printf("hello, I am Legendtkl");
    return 42;
}

再编写一个测试程序:test.c。

#include "legendtkl.h"

int main() {
    foo();
    bar();
    return 0;
}

静态链接方式(-v 是为了看到具体的步骤):

$ gcc -v test.c legendtkl.c -o test1

动态链接需要先构建我们自己的 so 文件。

$ gcc -shared -fpic -o liblegendtkl.so legendtkl.c

然后编译。

$ gcc -v test.c -o test2 ./liblegendtkl.so

test1 就是静态链接生成的可执行文件。test2 是动态链接生成的目标文件。test1 移动到任何位置都可以运行;而 test2 的运行目录必须保证存在 liblegendtkl.so (可以将 so 传递绝对路径)。另外不出以为的话:test1 的文件大小要比 test2 要大。

动态链接还有一个好处是我们修改 legendtkl.c 然后编译成 so 文件,并把之前的 so 文件替换。运行 test2 发现我们新加的功能已经有了,毕竟动态链接。

1. Go 代码编译成 so

Go 代码编译成 so 也很简单,首先编写 Go 代码。

package main

import "C"

import (
    "fmt"
)

//export Foo
func Foo(a, b int) int {
    return a + b;
}

//export Bar
func Bar() {
    fmt.Println("I am bar, not foo!")
}

func main() {}

构建 so 文件。Go 构建出来的 so 文件比较大,因为 Go 有 runtime。

$ go build -o legendtkl.so -buildmode=c-shared legendtkl.go

这个时候正常会输出生成两个文件:legendtkl.h 和 legendtkl.so。头文件中负责做一些类型转换。截取部分内容如下。

#ifndef GO_CGO_PROLOGUE_H
#define GO_CGO_PROLOGUE_H

typedef signed char GoInt8;
typedef unsigned char GoUint8;
typedef short GoInt16;
typedef unsigned short GoUint16;
typedef int GoInt32;
typedef unsigned int GoUint32;
typedef long long GoInt64;
typedef unsigned long long GoUint64;
typedef GoInt64 GoInt;
typedef GoUint64 GoUint;

2. C 代码引用

编写测试代码:test.c。

#include "legendtkl.h"
#include <stdio.h>

int main() {
    printf("%lld\n",Foo(1,2));
    Bar();
    return 0;
}

编译

$ gcc test.c -o test ./legendtkl.so

运行

./test
3
I am bar, not foo!

3. 结语

其实生成 so 文件之后,其他语言也是可以调用的,这里就不赘述了。

此专栏长期接受投稿,欢迎大家投稿 Go 相关的原创文章。