Golang 在某些领域精巧易用,完成一些常用功能简单快捷。而且是编译为可执行单文件,发布非常方便。但是 Golang 对界面支持非常不好,虽然有解决方案,但是其实很难用。编写桌面端的界面程序还是 C# 来的直接,拖几个控件太方便了。

而网络和某些组件,用 Golang 不要太方便,C#相应的包要么难用,要么没有,自己写工作量是吃不消的。很久以前就有把 Golang 编译为 DLL 的想法,不过直到 1.10 Golang 终于支持编译 Windows 动态链接库了,当然 Linux 版本早就支持了。

我现在的 Golang 版本是 1.11,所有代码都是在用 Golang 1.11 下写的。其实就是一条编译指令,使用非常简单。

go build -ldflags "-s -w" -o ..\..\C++\DllTest\main.dll -buildmode=c-shared main.go

上面的 "-s -w" 选项用于减小生成动态链接库的体积,-s 是压缩,-w 是去掉调试信息。当然,这条指令可以简化成:

go build -o ..\..\C++\DllTest\main.dll -buildmode=c-shared main.go

-o 指定目标目录,为了方便,我直接把目标文件编译到了 C++ 测试工程的目录。编译通过后,在目标目录生成一个 main.dll 文件和一个 main.h 文件,没有 .lib 文件。所以测试使用动态加载的方式。

把 Golang 代码编译为动态链接库的时候,main 函数不能省略,因为编译器硬性需要这个函数,当然,它是不会被调用的。但是,每个文件的 init 函数是可以正常被调用的。也就是加载 DLL 的时候,这些 init 会被初始化调用。

整个过程非常简单,但是真正需要注意的是,虽然 Golang 提供了 GoSlice 这样的导出类型对应到 Golang 的数组(Slice),但是通常你不能真正把它用在导出函数上。因为 Golang 是一个有垃圾回收机制的语言,那么一个数组什么时候被回收,显然离开 Golang 的环境是无法解决的,Golang 的垃圾收集器无法知道你在外部对这个数组做了什么,什么时候应该回收它。所以,如果处理不当,这种函数一调用就会出错,因为在函数尚未返回的时候,垃圾收集已经工作了,导致变量已经失效,从而异常。

编写DLL和谐的问题并不是编译,而是CGO的相关知识。Golang和C语言的交互很方便,只要 import "C" 后,直接用 C. 前缀调用 C 的函数就可以了。

package main

/*
#include <stdio.h>

void CHello(void) {
	printf("hello world! I from c code!\n");
}
*/
import "C"

func main() {
	C.CHello()
}

需要注意的是,import "C" 上面的注释部分,必须紧挨,不能有空行,否则编译就会报错。Go 语言可以把 C 代码写到注释部分有一点诡异。当然,Go 支持和 C 文件混编,可以把 C 代码单独写成 .h .c 文件。

CGO 的交互包含两部分,一是上面的 Go 调用 C 代码,另一个就是 C 调用 Go 的代码。可能是因为 C 语言引用的关系,如果这两种调用都有,就必须是多文件模式,不同部分代码写在不同的文件,否则,就会编译报错(重复定义错误)。

创建三个文件 lib.h lib.c lib.go

//lib.h
#include <stdio.h>
void GoHello();
void CHello();


//lib.c
#include "lib.h"

void CHello(void) {
	printf("hello world! I from c code!\n");
}
//lib.go
package main

/*
#include "lib.h"
*/
import "C"
import "fmt"

//export GoHello
func GoHello() {
	fmt.Println("Hello world! I from Golang!")
}


修改 main.go 如下:

//main.go
package main

/*
#include "lib.h"
*/
import "C"

func main() {
	C.CHello()
	C.GoHello()
}
//使用 go run . 命令在当前目录下运行,输出如下:
hello world! I from c code!
Hello world! I from Golang!


现在还没有涉及到 CGO 最容易踩坑的地方,就是参数传递。一个显然是事情是,Golang 是一个有垃圾回收的语言,所以任何 Golang 的对象指针不应该直接传给 C 语言代码,因为 C 语言可以直接操作内存,会导致 Golang 的运行环境被破坏,产生不可预知的问题。我们用下面的代码来做实验。

修改 lib.h,添加一个结构和两个函数 GetData,SetData 分别用于获取数据和设置数据。

// lib.h
#include <string.h>
#include <stdio.h>
typedef struct{
    void* data;
    int size;
}GoMem;
void GetData(GoMem* gm);
void SetData(GoMem* gm);

修改 lib.go,添加两个函数的实现代码,此时 lib.c 没有用,可以先不管他。

//lib.go
package main

/*
#include "lib.h"
*/
import "C"
import (
	"fmt"
	"unsafe"
)

//export GetData
func GetData(gm *C.GoMem)  {
	data := []byte("Hello world! I'm a golang data.")
	gm.data = unsafe.Pointer(&data[0])
	gm.size = C.int(len(data))
}
//export SetData
func SetData(gm *C.GoMem)  {
	data := make([]byte,gm.size)
	fmt.Println(string(data))
}

修改 main.go,调用这两个函数。

//main.go
package main

/*
#include "lib.h"
*/
import "C"

func main() {
	var gm C.GoMem
	C.GetData(&gm)
	C.SetData(&gm)
}


编译执行,我们会发现,编译通过,但是执行过程中,C.SetData 这句会出错,提示传递了Golang的指针,这是不允许的。这句你可以理解成,CGO 不允许把 Golang 的指针传到外部,你只能传递基本数字数据类型。但是,第一个 C.GetData 确没有这个提示,虽然我们用它取出了 Golang 的指针。这是因为,在传入的时候,gm 还是初始状态,并没有有效指针,所以 Go 无法检测到。如果我们注释掉 C.SetData 的调用,添加如下代码:

//main.go
package main

/*
#include "lib.h"
*/
import "C"
import "fmt"

func main() {
	var gm C.GoMem
	C.GetData(&gm)
	//C.SetData(&gm)
	//打印指针数值,正确输出指针地址和大小
	fmt.Println("return:",gm.data,gm.size)
	//创建一个同样大小的数组
	data := make([]byte,gm.size)
	//使用 C 函数 memcpy 拷贝获取的数据到这个数组,需要添加 string.h 的引用
	//注意参数的类型转换,unsafe.Pointer 在 C 代码里就是 void*,最后的长度参数,在64位下,
	//是一个ulonglong 类型,gm.size 是 int 类型,需要转换一下。
	C.memcpy(unsafe.Pointer(&data[0]),gm.data,C.ulonglong(gm.size))
	//打印数据,可以看到正确的输出:Hello world! I'm a golang data.
	fmt.Println(string(data))
}
 

上面的代码其实是绕过了 Golang 的检测,虽然我们得到了正确的结果,但是这是有隐患的。GetData 返回的指针是一个 Golang 的对象,我们可以在 C 代码里使用和修改它,而此时,如果 Golang 的垃圾回收开始工作,这个指针会立即失效,从而造成程序宕机。之所以这个程序可以正确运行,是因为程序太简单,垃圾回收并没有启动。应该说,这是一个任何有垃圾回收的语言都面临的问题。

附上 C 语言类型在 Golang 里面的名称:

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

如何改造,其实也很简单,我们需要 Golang 数据的时候,自己使用 C 语言申请一段内存,然后在 Golang 里面拷贝数据到这个内存上,当然,这段内存使用完需要释放,否则就会造成内存泄露。

其实 Cgo 里面有 4 个非常方便的用于字串和字节转换的函数,C.CString,C.CBytes,C.GoString,C.GoBytes,当然还有一个支持非 0 结尾字符串的 C.GoStringN。我们修改 lib.go 如下:

//lib.go
package main

/*
#include "lib.h"
*/
import "C"
import (
	"fmt"
	"unsafe"
)

//export GetData
func GetData(gm *C.GoMem)  {
	str := "Hello world! I'm a golang data."
	//C.CString 返回一个 0 结尾的数组,它的类型是 *C.char,这个指针需要用 C.free 来释放
	gm.data = unsafe.Pointer(C.CString(str))
	//C.CBytes 返回一个和源数组同长度的字节数组,它的类型是 C.uchar,这个指针需要用 C.free 来释放
	//gm.data = unsafe.Pointer(C.CBytes([]byte(str)))
	gm.size = C.int(len(str))
}
//export SetData
func SetData(gm *C.GoMem)  {
	//从指针直接返回一个字串,这个指针指向的字节数组必须是 0 结尾的。
	str := C.GoString((*C.char)(gm.data))
	//从一个字节数组和数组长度构造一个字串,这个自己数组不需要是 0 结尾的。
	//str = C.GoStringN((*C.char)(gm.data),gm.size)
	fmt.Println("GoString:",str)
	//从一个字节数组返回一个 Golang 数组
	data := C.GoBytes(unsafe.Pointer(gm.data),gm.size)
	fmt.Println("GoBytes:",string(data))
}

修改 main.go 如下:

//main.go
package main

/*
#include "lib.h"
*/
import "C"
import (
	"fmt"
	"unsafe"
)

func main() {
	var gm C.GoMem
	C.GetData(&gm)
	C.SetData(&gm)
	fmt.Println("return:",gm.data,gm.size)
	//因为使用了 free 函数,lib.h 里面需要添加 #include <stdlib.h>
	C.free(unsafe.Pointer(gm.data))
}

至此,我们解决了 cgo 互调,内存数据的传输问题。下一步开始编写 DLL,并且在其它语言测试。

对 lib.go 稍微修改了一下,去掉了额外的测试代码。

//lib.go
package main

/*
#include "lib.h"
*/
import "C"
import (
	"fmt"
	"unsafe"
)

//export GetData
func GetData(gm *C.GoMem)  {
	str := "这是一段中文文本,Golang 字符串编码是 utf-8,在 C 语言里需要转换成 GBK 编码才能正确显示。"
	gm.data = unsafe.Pointer(C.CString(str))
	gm.size = C.int(len(str))
}
//export SetData
func SetData(gm *C.GoMem)  {
	var str string
	if gm.size==0{
		str = C.GoString((*C.char)(gm.data))
	}else{
		str = C.GoStringN((*C.char)(gm.data),gm.size)
	}
	fmt.Println("SetData:",str)
}

此时要编译 dll 文件,实际上我们只需要 lib.h 和 lib.go 两个文件,因为全部代码都在这两个文件里,但是 main.go 是必须的,main 函数也是必须的,虽然它不会被调用。此时我们不对它进行改动,因为这里面的代码不会被打包进 dll。

在当前文件夹,使用命令 go build -o main.dll -buildmode=c-shared 编译 main.dll,成功后,生成 main.dll 和 main.h 两个文件,其中 main.h 除了基本的函数和类型定义,还会引用 lib.h 文件。

创建 main.c 文件:

//main.c
#include <stdlib.h>
#include <windows.h>
#include "main.h"

//声明函数类型
typedef void (*GETDATA)(GoMem*);
typedef void (*SETDATA)(GoMem*);

int main() {
	//加载动态链接库
	HMODULE hdll = LoadLibrary("main.dll");
	//获取函数指针
	GETDATA GetData = (GETDATA)GetProcAddress(hdll, "GetData");
	SETDATA SetData = (SETDATA)GetProcAddress(hdll, "SetData");
	//检测指针是否加载成功,如果不为 0,就是加载成功了
	printf("dll:%x,GetData:%x,SetData:%x\n", hdll, GetData, SetData);

	//调用 GetData 函数,并且打印取回的指针和大小
	GoMem gm;
	GetData(&gm);
	printf("data:%x,size:%d\n", gm.data, gm.size);

	//调用 SetData 函数
	SetData(&gm);

	//释放内存指针,这一步非常重要
	free(gm.data);
}
// 输出如下:
// dll:6a940000,GetData:6a9d0b70,SetData:6a9d0bb0
// data:26f2680,size:123
// SetData: 这是一段中文文本,Golang 字符串编码是 utf-8,在 C 语言里需要转换成 GBK 编码才能正确显示。

我们使用 MinGW 编译它,注意是 64 位版本,因为我们的 DLL 是 64位的,执行命令 gcc main.c,gcc自动编译为 a.exe 文件,执行 a.exe,可以看到输出结果。