当前位置: 首页 > 工具软件 > go-callvis > 使用案例 >

Go 与 C 语言的互操作

郗欣嘉
2023-12-01

1. Go 与 C 语言的互操作

Go 有强烈的 C 背景, 除了语法具有继承性外, 其设计者以及其设计目标都与 C 语言有着千丝万缕的联系。在 Go 与 C 语言互操作 (Interoperability) 方面, Go 更是提供了强大的支持。尤其是在 Go 中使用 C, 你甚至可以直接在 Go 源文件中编写 C 代码, 这是其他语言所无法望其项背的。

在如下一些场景中, 可能会涉及到 Go 与 C 的互操作:

  1. 提升局部代码性能时, 用 C 替换一些 Go 代码。C 之于 Go, 好比汇编之于 C。
  2. 嫌 Go 内存 GC 性能不足, 自己手动管理应用内存。
  3. 实现一些库的 Go Wrapper。比如 Oracle 提供的 C 版本 OCI, 但 Oracle 并未提供 Go 版本的以及连接 DB 的协议细节, 因此只能通过包装 C OCI 版本的方式以提供 Go 开发者使用。
  4. Go 导出函数供 C 开发者使用(目前这种需求应该很少见)。
  5. Maybe more…

1.1. Go 调用 C 代码的原理

下面是一个短小的例子:

package main
 
// #include <stdio.h>
// #include <stdlib.h>
/*
void print(char *str) {
    printf("%s\n", str);
}
*/
import "C"
 
import "unsafe"
 
func main() {
    s := "Hello Cgo"
    cs := C.CString(s)
    C.print(cs)
    C.free(unsafe.Pointer(cs))
}

与 "正常"Go 代码相比, 上述代码有几处 “特殊” 的地方:

  1. 在开头的注释中出现了 C 头文件的 include 字样
  2. 在注释中定义了 C 函数 print
  3. import 的一个名为 C 的 “包”
  4. 在 main 函数中居然调用了上述的那个 C 函数 - print

没错, 这就是在 Go 源码中调用 C 代码的步骤, 可以看出我们可直接在 Go 源码文件中编写 C 代码。

首先, Go 源码文件中的 C 代码是需要用注释包裹的, 就像上面的 include 头文件以及 print 函数定义;
其次, import “C” 这个语句是必须的, 而且其与上面的 C 代码之间不能用空行分隔, 必须紧密相连。这里的 “C” 不是包名, 而是一种类似名字空间的概念, 或可以理解为伪包, C 语言所有语法元素均在该伪包下面;
最后, 访问 C 语法元素时都要在其前面加上伪包前缀, 比如 C.uint 和上面代码中的 C.print、C.free 等。

我们如何来编译这个 go 源文件呢? 其实与 "正常"Go 源文件没啥区别, 依旧可以直接通过 go build 或 go run 来编译和执行。但实际编译过程中, go 调用了名为 cgo 的工具, cgo 会识别和读取 Go 源文件中的 C 元素, 并将其提取后交给 C 编译器编译, 最后与 Go 源码编译后的目标文件链接成一个可执行程序。这样我们就不难理解为何 Go 源文件中的 C 代码要用注释包裹了, 这些特殊的语法都是可以被 Cgo 识别并使用的。

1.2. 在 Go 中使用 C 语言的类型

1.2.1. 原生类型

1.2.1.1. 数值类型

在 Go 中可以用如下方式访问 C 原生的数值类型:

C.char,
C.schar (signed char),
C.uchar (unsigned char),
C.short,
C.ushort (unsigned short),
C.int, C.uint (unsigned int),
C.long,
C.ulong (unsigned long),
C.longlong (long long),
C.ulonglong (unsigned long long),
C.float,
C.double

Go 的数值类型与 C 中的数值类型不是一一对应的。因此在使用对方类型变量时少不了显式转型操作, 如 Go doc 中的这个例子:

func Random() int {
    return int(C.random())//C.long -> Go 的 int
}
 
func Seed(i int) {
    C.srandom(C.uint(i))//Go 的 uint -> C 的 uint
}

1.2.1.2. 指针类型

原生数值类型的指针类型可按 Go 语法在类型前面加上 *, 比如 var p *C.int。而 void * 比较特殊, 用 Go 中的 unsafe.Pointer 表示。任何类型的指针值都可以转换为 unsafe.Pointer 类型, 而 unsafe.Pointer 类型值也可以转换为任意类型的指针值。unsafe.Pointer 还可以与 uintptr 这个类型做相互转换。由于 unsafe.Pointer 的指针类型无法做算术操作, 转换为 uintptr 后可进行算术操作。

1.2.1.3. 字符串类型

C 语言中并不存在正规的字符串类型, 在 C 中用带结尾’\0’的字符数组来表示字符串; 而在 Go 中, string 类型是原生类型, 因此在两种语言互操作是势必要做字符串类型的转换。

通过 C.CString 函数, 我们可以将 Go 的 string 类型转换为 C 的 “字符串” 类型, 再传给 C 函数使用。就如我们在本文开篇例子中使用的那样:

s := "Hello Cgo\n"
cs := C.CString(s)
C.print(cs)

不过这样转型后所得到的 C 字符串 cs 并不能由 Go 的 gc 所管理, 我们必须手动释放 cs 所占用的内存, 这就是为何例子中最后调用 C.free 释放掉 cs 的原因。在 C 内部分配的内存, Go 中的 GC 是无法感知到的, 因此要记着释放。

通过 C.GoString 可将 C 的字符串 (*C.char) 转换为 Go 的 string 类型, 例如:

// #include <stdio.h>
// #include <stdlib.h>
// char *foo = "hellofoo";
import "C"
 
import "fmt"
 
func main() {
… …
    fmt.Printf("%s\n", C.GoString(C.foo))
}

1.2.1.4. 数组类型

C 语言中的数组与 Go 语言中的数组差异较大, 后者是值类型, 而前者与 C 中的指针大部分场合都可以随意转换。目前似乎无法直接显式的在两者之间进行转型, 官方文档也没有说明。但我们可以通过编写转换函数, 将 C 的数组转换为 Go 的 Slice(由于 Go 中数组是值类型, 其大小是静态的, 转换为 Slice 更为通用一些), 下面是一个整型数组转换的例子:

// int cArray[] = {1, 2, 3, 4, 5, 6, 7};
 
func CArrayToGoArray(cArray unsafe.Pointer, size int) (goArray []int) {
    p := uintptr(cArray)
    for i :=0; i < size; i++ {
        j := *(*int)(unsafe.Pointer(p))
        goArray = append(goArray, j)
        p += unsafe.Sizeof(j)
    }
 
    return
}
 
func main() {
    … …
    goArray := CArrayToGoArray(unsafe.Pointer(&C.cArray[0]), 7)
    fmt.Println(goArray)
}

执行结果输出: [1 2 3 4 5 6 7]

这里要注意的是: Go 编译器并不能将 C 的 cArray 自动转换为数组的地址, 所以不能像在 C 中使用数组那样将数组变量直接传递给函数, 而是将数组第一个元素的地址传递给函数。

1.2.1.5. 自定义类型

除了原生类型外, 我们还可以访问 C 中的自定义类型。

1.2.1.5.1. 枚举(enum)
// enum color {
//    RED,
//    BLUE,
//    YELLOW
// };
 
var e, f, g C.enum_color = C.RED, C.BLUE, C.YELLOW
fmt.Println(e, f, g)

输出: 0 1 2

对于具名的 C 枚举类型, 我们可以通过 C.enum_xx 来访问该类型。如果是匿名枚举, 则似乎只能访问其字段了。

1.2.1.5.2. 结构体(struct)
// struct employee {
//     char *id;
//     int  age;
// };
 
id := C.CString("1247")
var employee C.struct_employee = C.struct_employee{id, 21}
fmt.Println(C.GoString(employee.id))
fmt.Println(employee.age)
C.free(unsafe.Pointer(id))

输出:

1247
21

和 enum 类似, 我们可以通过 C.struct_xx 来访问 C 中定义的结构体类型。

1.2.1.5.3. 联合体(union)

这里我试图用与访问 struct 相同的方法来访问一个 C 的 union:

// #include <stdio.h>
// union bar {
//        char   c;
//        int    i;
//        double d;
// };
import "C"
 
func main() {
    var b *C.union_bar = new(C.union_bar)
    b.c = 4
    fmt.Println(b)
}

不过编译时, go 却报错: b.c undefined (type *[8]byte has no field or method c)。从报错的信息来看, Go 对待 union 与其他类型不同, 似乎将 union 当成[N]byte 来对待, 其中 N 为 union 中最大字段的 size(圆整后的), 因此我们可以按如下方式处理 C.union_bar:

func main() {
    var b *C.union_bar = new(C.union_bar)
    b[0] = 13
    b[1] = 17
    fmt.Println(b)
}

输出: &[13 17 0 0 0 0 0 0]

1.2.1.5.4. typedef

在 Go 中访问使用用 typedef 定义的别名类型时, 其访问方式与原实际类型访问方式相同。如:

// typedef int myint;
 
var a C.myint = 5
fmt.Println(a)
 
// typedef struct employee myemployee;
 
var m C.struct_myemployee

从例子中可以看出, 对原生类型的别名, 直接访问这个新类型名即可。而对于复合类型的别名, 需要根据原复合类型的访问方式对新别名进行访问, 比如 myemployee 实际类型为 struct, 那么使用 myemployee 时也要加上 struct_ 前缀。

1.3. Go 中访问 C 的变量和函数

实际上上面的例子中我们已经演示了在 Go 中是如何访问 C 的变量和函数的, 一般方法就是加上 C 前缀即可, 对于 C 标准库中的函数尤其是这样。不过虽然我们可以在 Go 源码文件中直接定义 C 变量和 C 函数, 但从代码结构上来讲, 大量的在 Go 源码中编写 C 代码似乎不是那么 “专业”。那如何将 C 函数和变量定义从 Go 源码中分离出去单独定义呢? 我们很容易想到将 C 的代码以共享库的形式提供给 Go 源码。

Cgo 提供了 #cgo 指示符可以指定 Go 源码在编译后与哪些共享库进行链接。我们来看一下例子:

package main
 
// #cgo LDFLAGS: -L ./ -lfoo
// #include <stdio.h>
// #include <stdlib.h>
// #include "foo.h"
import "C"
import "fmt"
 
func main() {
    fmt.Println(C.count)
    C.foo()
}

我们看到上面例子中通过 #cgo 指示符告诉 go 编译器链接当前目录下的 libfoo 共享库。C.count 变量和 C.foo 函数的定义都在 libfoo 共享库中。我们来创建这个共享库:

// foo.h
 
int count;
void foo();
 
//foo.c
#include "foo.h"
 
int count = 6;
void foo() {
    printf("I am foo!\n");
}
$> gcc -c foo.c
$> ar rv libfoo.a foo.o

我们首先创建一个静态共享库 libfoo.a, 不过在编译 Go 源文件时我们遇到了问题:

$> go build foo.go
# command-line-arguments
/tmp/go-build565913544/command-line-arguments.a(foo.cgo2.)(.text): foo: not defined
foo(0): not defined

提示 foo 函数未定义。通过 - x 选项打印出具体的编译细节, 也未找出问题所在。不过在 Go 的问题列表中我发现了一个 issue(http://code.google.com/p/go/issues/detail?id=3755), 上面提到了目前 Go 的版本不支持链接静态共享库。

那我们来创建一个动态共享库试试:

$> gcc -c foo.c
$> gcc -shared -Wl,-soname,libfoo.so -o libfoo.so  foo.o

再编译 foo.go, 的确能够成功。执行 foo。

$> go build foo.go && go
6
I am foo!

还有一点值得注意, 那就是 Go 支持多返回值, 而 C 中并没不支持。因此当将 C 函数用在多返回值的调用中时, C 的 errno 将作为 err 返回值返回, 下面是个例子:

package main
 
// #include <stdlib.h>
// #include <stdio.h>
// #include <errno.h>
// int foo(int i) {
//    errno = 0;
//    if (i > 5) {
//        errno = 8;
//        return i – 5;
//    } else {
//        return i;
//    }
//}
import "C"
import "fmt"
 
func main() {
    i, err := C.foo(C.int(8))
    if err != nil {
        fmt.Println(err)
    } else {
        fmt.Println(i)
    }
}
$> go run foo.go
exec format error

errno 为 8, 其含义在 errno.h 中可以找到:

#define ENOEXEC      8  /* Exec format error */

的确是 “exec format error”。

1.4. C 中使用 Go 函数

与在 Go 中使用 C 源码相比, 在 C 中使用 Go 函数的场合较少。在 Go 中, 可以使用 “export + 函数名” 来导出 Go 函数为 C 所使用, 看一个简单例子:

package main
 
/*
#include <stdio.h>
 
extern void GoExportedFunc();
 
void bar() {
        printf("I am bar!\n");
        GoExportedFunc();
}
*/
import "C"
 
import "fmt"
 
//export GoExportedFunc
func GoExportedFunc() {
        fmt.Println("I am a GoExportedFunc!")
}
 
func main() {
        C.bar()
}

不过当我们编译该 Go 文件时, 我们得到了如下错误信息:

# command-line-arguments
/tmp/go-build163255970/command-line-arguments/_obj/bar.cgo2.o: In function `bar':
./bar.go:7: multiple definition of `bar'
/tmp/go-build163255970/command-line-arguments/_obj/_cgo_export.o:/home/tonybai/test/go/bar.go:7: first defined here
collect2: ld returned 1 exit status

代码似乎没有任何问题, 但就是无法通过编译, 总是提示 “多重定义”。翻看 Cgo 的文档, 找到了些端倪。原来

There is a limitation: if your program uses any //export directives, then the C code in the comment may only include declarations (extern int f();), not definitions (int f() { return 1; }).

似乎是 // extern int f()//export f 不能放在一个 Go 源文件中。我们把 bar.go 拆分成 bar1.gobar2.go 两个文件:

// bar1.go
 
package main
 
/*
#include <stdio.h>
 
extern void GoExportedFunc();
 
void bar() {
        printf("I am bar!\n");
        GoExportedFunc();
}
*/
import "C"
 
func main() {
        C.bar()
}
 
// bar2.go
 
package main
 
import "C"
import "fmt"
 
//export GoExportedFunc
func GoExportedFunc() {
        fmt.Println("I am a GoExportedFunc!")
}

编译执行:

$> go build -o bar bar1.go bar2.go
$> bar
I am bar!
I am a GoExportedFunc!

个人觉得目前 Go 对于导出函数供 C 使用的功能还十分有限, 两种语言的调用约定不同, 类型无法一一对应以及 Go 中类似 Gc 这样的高级功能让导出 Go 函数这一功能难于完美实现, 导出的函数依旧无法完全脱离 Go 的环境, 因此实用性似乎有折扣。

1.5. 其他

虽然 Go 提供了强大的与 C 互操作的功能, 但目前依旧不完善, 比如不支持在 Go 中直接调用可变个数参数的函数(issue975), 如 printf(因此, 文档中多用 fputs)。

这里的建议是: 尽量缩小 Go 与 C 间互操作范围。

什么意思呢? 如果你在 Go 中使用 C 代码时, 那么尽量在 C 代码中调用 C 函数。Go 只使用你封装好的一个 C 函数最好。不要像下面代码这样:

C.fputs(…)
C.atoi(..)
C.malloc(..)

而是将这些 C 函数调用封装到一个 C 函数中, Go 只知道这个 C 函数即可。

C.foo(..)

相反, 在 C 中使用 Go 导出的函数也是一样。

 类似资料: