C语言竟可以调用Go语言函数,这是如何实现的?

2024-05-27 12:43:46 浏览数 (2)

大家好,我是飞哥!

今天和大家聊一个问题,一门语言是否可以在同一个进程内调用另外一门语言实现的函数?例如 C 语言是否可以调用 Golang 实现的函数?注意我说的是同进程内调用,跨进程的 IPC、PRC 之类的技术不算。

直接抛出这个问题的答案,同进程跨语言调用是可行的。在各种语言设计时,为了复用其它语言生态中积累下来的大量的代码资产,都会实现跨语言调用的机制。例如在 Golang 中,实现了 cgo 机制来允许 Golang 和 C / C 互相调用。在 Java 中,允许通过 JNI 机制来调用 C / C 。

本文就以 C 调用 Golang 为例,来带大家了解下跨语言调用的底层实现原理。

一、C 调用 Go 函数的例子

一个 C 调用 Go 的程序实现大致可以分为下面三个步骤:

  • 第一步:使用 Golang 定义和实现一个函数
  • 第二步:将 Golang 代码编译成一个静态/动态链接库
  • 第三步:在 C 语言中调用该静态/动态链接库

我们先来看一个最简单的例子,看看 C 语言调用 Go 函数该如何使用的。

1.1 Go 函数定义和实现

我们先用 Golang 来定义和实现一个最简单的加法函数。

代码语言:javascript复制
package main

//int add(int a, int b);
import "C"

//export add
func add(a, b C.int) C.int {
 return a   b
}

func main() {
}

上面的代码中,虽然代码不长,但有好几个需要注意的地方:

  • import "C" 这行表示是使用 CGO 特性。有了这一行代码,go build 命令会在编译和链接阶段启动 gcc 编译器。
  • //int add(int a, int b) 。这一行其实不是注释,是正常的 C 语言的代码,声明了一个 add 函数。
  • add 函数实现上面的 export add。这是在将 add 函数导出,否则外部无法调用它。
  • add 函数中的参数类型,只能使用 C.int。这是因为不同语言的数据类型是可能有细微差异的,必须使用标准的 cgo 数据类型才可以正常通信。

1.2 将 Go 代码编译成库

接下来需要将 Go 写的函数编译成一个静态/动态库。我们采用 go build 来编译。

代码语言:javascript复制
# go build -buildmode=c-shared -o libadd.dylib main.go

在上述命令中,

  • -buildmode=c-shared 指的是要把 go 代码编译成动态链接库。如果想编译成静态链接库,则使用 -buildmode=c-archive。
  • -o libadd.dylib 是指定编译生成的动态库名。由于我使用是 mac,所以动态链接库的文件后缀是 dylib。

执行上述命令后,go 编译器会将 go 源代码编译后生成一个头文件 libadd.h,还有一个包含 add 函数二进制代码的动态库,这个函数满足 C 语言调用约定的。库的格式在 mac 下是 libadd.dylib(在 linux 下是 libadd.so)。

1.3 C 语言调用库中函数

接着我们再写一小段简单的 C 语言代码,来调用动态库中的 add 函数。

代码语言:javascript复制
#include <stdio.h>
#include "libadd.h"

int main(void)
{
    int ret = add(2,3);
    printf("C调用Go函数2 3= %d", ret);
    return 0;
}

在这个 C 语言函数中,把libadd.h 头文件引用一下,就可以使用 add 函数了。

然后编译和链接这个程序。注意使用 -L 选项指定要链接的库的位置。-l 选项指定要链接的库的名字,链接器会寻找以 libxxx 为名的动态库。

代码语言:javascript复制
# gcc main.c -L. -ladd -o main

编译完后,会生成一个可执行文件 main。执行该文件,发现 C 调用 Go 函数 add 成功!!

代码语言:javascript复制
# ./main
C调用Go函数2 3=5

二、C 调用 Go 函数实现原理

只说技术如何使用不讲原理,从来都不是咱们「开发内功修炼」的风格。在这一节中,我们来深入了解下 C 调用 Go 函数内部是如何实现的。

2.1 cgo 编译工具

幸运的是,cgo 编译工具不但可以胜任编译工作,还把编译过程的中间文件也能展示出来。这对大家理解其内部工作方式非常有帮助。

我们用 cgo 来生成一下中间编译过程文件

代码语言:javascript复制
# go tool cgo main.go

cgo 首先会为每个包含 import "C" 指令的 go 源文件生成两个中间文件。我们使用的文件名是 main.go,所以生成的文件名是 main.cgo1.go、main.cgo2.c。

接着对会整个 main 包生成一个 _cgo_gotypes.go,这里面包含了 Go 语言一些辅助代码。

最后会生成包含导出的 C 语言 add 的入口函数以及其头文件,_cgo_export.c 和 _cgo_export.h。

生成的文件都放在 _obj 目录下。

代码语言:javascript复制
# ll _obj
-rw-r--r--   1 ...  2216  4 22 08:35 _cgo_.o
-rw-r--r--   1 ...  1090  4 22 08:35 _cgo_export.c
-rw-r--r--   1 ...  1652  4 22 08:35 _cgo_export.h
-rw-r--r--   1 ...    13  4 22 08:35 _cgo_flags
-rw-r--r--   1 ...  1013  4 22 08:35 _cgo_gotypes.go
-rw-r--r--   1 ...   653  4 22 08:35 _cgo_main.c
-rw-r--r--   1 ...   324  4 22 08:35 main.cgo1.go
-rw-r--r--   1 ...  2028  4 22 08:35 main.cgo2.c

2.2 函数调用入口 _cgo_export.c

在 C 语言代码中调用 add 函数时,最先进入的是位于 _cgo_export.c 中的调用入口。该入口函数定义如下:

代码语言:javascript复制
int add(int a, int b)
{
 __SIZE_TYPE__ _cgo_ctxt = _cgo_wait_runtime_init_done();
 typedef struct {
  int p0;
  int p1;
  int r0;
 } __attribute__((__packed__)) _cgo_argtype;
 static _cgo_argtype _cgo_zero;
 _cgo_argtype _cgo_a = _cgo_zero;
 _cgo_a.p0 = a;
 _cgo_a.p1 = b;
 ...
 crosscall2(_cgoexp_ec46b88da812_add, &_cgo_a, 12, _cgo_ctxt);
 ...
 return _cgo_a.r0;
}

在这个函数源码中主要包含两块功能:

第一是对 Go 语言运行时的初始化,这是由 _cgo_wait_runtime_init_done 函数完成的。因为 Go 函数还是需要由 Go 运行时来执行,所以确保 Go 运行时已经初始化是必要的。

第二是调用 runtime 的 crosscall2 函数,把调用转交给 Go runtime 来处理。

在调用runtime 的 crosscall2 之前,先定义了一个包含所有输入参数、输出参数的 _cgo_argtype,并将 C 语言输入的两个参数打包进来。这样访问 _cgo_a 就可以获取到所有的输入和输出参数。

另外也告诉 runtime 该函数的在 Go 语言实现的入口函数名是 _cgoexp_ec46b88da812_add。等 runtime 中的逻辑执行完了后要回调这个函数来进一步执行指。

2.3 Go runtime cgo 执行

在上一小节我们看到了 Go runtime 的入口是 crosscall2 函数。这是一个纯汇编写的函数,其源码位于 runtime/cgo/asm_amd64.s 文件中。

代码语言:javascript复制
//file:runtime/cgo/asm_amd64.s
TEXT crosscall2(SB),NOSPLIT,$0-0
 PUSH_REGS_HOST_TO_ABI0()

 // Make room for arguments to cgocallback.
 ADJSP $0x18
 MOVQ CX, 0x0(SP) /* fn */
 MOVQ DX, 0x8(SP) /* arg */
 // Skip n in R8.
 MOVQ R9, 0x10(SP) /* ctxt */

 CALL runtime·cgocallback(SB)

 ADJSP $-0x18
 POP_REGS_HOST_TO_ABI0()
 RET

在计算机体系结构和编程中,ABI 定义了函数调用的约定,包括寄存器使用、参数传递等。不同的语言采用的 ABI 调用习惯是不一样的。

这里因为要从 C 语言进入到 Go 语言中运行,所以在 crosscall2 的开头和结尾处有 PUSH_REGS_HOST_TO_ABI0、POP_REGS_HOST_TO_ABI0 两个函数。PUSH_REGS_HOST_TO_ABI0 函数作用是保存调用方使用的寄存器。等调用结束后再通过 POP_REGS_HOST_TO_ABI0 恢复这些寄存器的值。

接着通过 ADJSP 指令将栈扩张一些,把输入参数入栈,然后调用 runtime·cgocallback 函数来进一步在 runtime 中运行。

代码语言:javascript复制
//file:runtime/asm_amd64.s
TEXT ·cgocallback(SB),NOSPLIT,$24-24
 ......
havem:
 // 保存线程栈
 MOVQ m_g0(BX), SI
 ...

 // 切换到协程栈
 MOVQ m_curg(BX), SI
 ...

 // 调用 runtime.cgocallbackg
 MOVQ $runtime·cgocallbackg(SB), AX
 ...

cgocallback 函数很长,我对它进行了提炼和精简。这里面主要是进行了栈的切换。因为 C 语言是使用线程来运行的,而 Go 是使用协程来执行。所以这里就需要保存线程栈,并切换到协程栈,然后才能够进入到 Go 函数中继续执行。执行完栈切换后,交由 runtime·cgocallbackg 中进一步处理。

代码语言:javascript复制
//file:runtime/cgocall.go
func cgocallbackg(fn, frame unsafe.Pointer, ctxt uintptr) {
 gp := getg()

 lockOSThread()

 ...
 cgocallbackg1(fn, frame, ctxt) 
 ...
}

在 cgocallbackg 函数中,已经是由协程来执行处理了。但这时候还要调用 lockOSThread 来告诉 Go 的调度器,要把当前的协程绑定在当前线程上运行,不要把它交给其它线程。

接着调用 cgocallbackg1,在这个函数中调用 reflectcall,正式进入到用户定义的 Go 函数。

代码语言:javascript复制
func cgocallbackg1(fn, frame unsafe.Pointer, ctxt uintptr) {
 gp := getg()
 ...

 var cb func(frame unsafe.Pointer)
 cbFV := funcval{uintptr(fn)}
 *(*unsafe.Pointer)(unsafe.Pointer(&cb)) = noescape(unsafe.Pointer(&cbFV))
 cb(frame)
 ...
}

2.4 Go 语言函数

从runtime出来后首先进入到的函数并不是我们写的 Go 函数代码,而是由 cgo 生成的一个桩代码。在桩代码中,是为了将参数转换成 Go 正常的参数列表。

这个桩代码位于 _cgo_gotypes.go 文件中。

代码语言:javascript复制
//go:cgo_export_dynamic add
//go:linkname _cgoexp_ec46b88da812_add _cgoexp_ec46b88da812_add
//go:cgo_export_static _cgoexp_ec46b88da812_add
func _cgoexp_ec46b88da812_add(a *struct {
  p0 _Ctype_int
  p1 _Ctype_int
  r0 _Ctype_int
 }) {
 a.r0 = add(a.p0, a.p1)
}

通过代理函数调用到 main.go 函数中定义的 add 函数。

代码语言:javascript复制
//export add
func add(a, b C.int) C.int {
 return a   b
}

经过漫长的路径,一个 C 函数调用 Go 函数的执行流总算是打通了。

三、总结

我们来总结一下 C 语言调用 Go 语言函数的底层执行过程。

总体上来看,跨语言的调用是由三部分代码来配合运行的。分别是用户代码、cgo生成的桩代码、Go语言运行时。

  • 在 C 语言代码中调用 add 函数时,最先进入的是位于 _cgo_export.c 中的桩代码。
  • 接着进入 Go 语言运行时,包括 crosscall2、·cgocallback、cgocallbackg、cgocallbackg1 等函数。在这些运行时函数中保存了调用方的寄存器、将线程栈切换到了协程栈、把协程锁定到当前线程上运行。
  • Go 语言运行时执行完后,通过 _cgo_gotypes.go 中的桩代码 _cgoexp_ec46b88da812_add 函数后真正进入到 Go 函数中运行。

我们在很早的一篇函数调用太多了会有性能问题吗? 文章中曾经分析过 C 语言内部的函数开销。每个 C 语言函数大概只需要 8 个指令,平均耗时 0.43 纳秒。

通过今天的文章我们可以看到跨语言的函数调用的执行过程是非常复杂的,要比语言内部的函数调用要复杂的多。所以在性能上开销也是要大于普通函数调用。但由于仍然是属于进程内部的调用,不像 RPC 一样需要进行内核协议栈处理、协议序列化/反序列化。所以还是比 RPC 调用性能要好的。也就是说在性能开销上,语言内函数调用 < 跨语言函数调用 < RPC调用

0 人点赞