在CGO中集成和调用DLL文件

前言 #

CGO是Go语言中的一种机制,用于调用C代码或集成C库,它可以让我们通过Go程序直接使用C语言写的库文件(如.dll文件)。这种功能在需要利用已有的C库时非常有用,尤其是在Windows环境下的DLL文件调用。

本文将围绕如何在CGO中集成和调用DLL文件,介绍相关步骤和注意事项。

本文不会涉及到C动态库的内部实现,重点是使用CGO调用动态库的方法介绍

不同类型C库的分发方式 #

要使用C库,首先需要对不同类型库的相关文件有一个基本认识,c库通常有以下两种类型

  • 静态库
    • 头文件(*.h)
    • 库文件(.lib/.a)
  • 动态库
    • 头文件(*.h)
    • 库文件(.lib/.a)(这里的lib库是导入库类型,包含函数所在的DLL文件和文件中函数位置的信息,并没有具体的实现)
    • 动态库(.dll/.so)

区分静态库和动态库 #

因为正常链接过程都是使用.lib或者.a的文件,在没有明确告知库类型的情况下,可能会不知道自己使用的是静态库还是动态库,因此需要一个方法来区分,以MSVC环境的为例

使用lib命令行工具

lib /list *库文件*

如果输出都是.obj目标文件,那么就是静态库,相反如果是.dll动态库,那么这个.lib文件就是动态库的导入库

lib /list avp.lib

CGO调用DLL动态库函数的方法 #

要使用CGO,需要确保Go编译器开启了cgo,可以用go env来检查,确保环境变量CGO_ENABLED=1(现在默认都是开启的,后面编译阶段如果出现问题可以排查一下是否是这个原因导致的);以及需要配置好MingW的环境,这步不再赘述

编译时链接动态库 #

编译时链接动态库是指在编译时通过#cgo LDFLAGS指定动态库,生成的可执行文件在运行时会自动依赖这些库。Go程序启动时会自动加载这些动态库,无需手动管理动态库的加载和函数的绑定。

和静态库的链接方法一样,在LDFLAGS中指定库目录以及链接的库即可

/*
#cgo LDFLAGS: -L${SRCDIR}/../lib -lavp
*/
import "C"

后续使用就引入头文件,直接调用库函数即可

/*
#include <stdlib.h>
#include "c_avp.h"
*/
import "C"

func (meTool *MediaTool) cGetVideoInfo(cInputVideoPath *C.char, needDecodeData C.bool) (result string) {
	// c_get_video_properties接口 在c_avp.h中声明
	result = C.GoString(C.c_get_video_properties(cInputImagePath, needDecodeData))
	return
}

这种方案的优缺点:

  • 优点
    • 简单方便:直接在编译时指定动态库路径和名称,使用时像调用普通C函数一样调用库中的函数。
    • 自动管理:Go程序启动时自动加载动态库,无需手动加载和绑定函数。
    • 代码清晰:使用CGO调用C库函数时无需额外的处理逻辑。
  • 缺点
    • 动态库路径依赖:运行时需要保证动态库文件存在于指定路径,否则会导致程序启动失败。
    • 灵活性较低:动态库在程序启动时就被加载,无法在运行时动态选择不同的库版本。

如果不想让每个使用动态库的可执行程序都带上这个依赖项,可以尝试下面更灵活的动态加载库方案

运行时动态加载库 #

以Windows平台为例,动态加载动态库需要手动管理整个DLL的生命周期,获取更加灵活的特性需要付出一些代码复杂度上的代价。

例如:调用avp.dll中获取视频属性的接口,提取视频的时长可以用下面的方式实现

/*
#include <stdlib.h>

#ifdef _WIN32
	// 使用windows.h内的动态库管理接口
	#include <windows.h>
	#include <stdio.h>
	#include <stdbool.h>

	// 调用接口的函数签名,需要与头文件中的声明保持一致
	typedef char* (*GetVideoInfoFunction)(const char*, bool);

	// 动态库句柄
	HMODULE avp_dll;
	GetVideoInfoFunction _c_get_video_properties;

	void free_avp() {
		if (avp_dll) {
		  // 释放资源
			FreeLibrary(avp_dll);
		}
	}

	int init_avp(const char* dll_path) {
		// 动态载入动态库
		avp_dll	= LoadLibrary(dll_path);
		if (!avp_dll) {
			return 1;
		}
	
		// 找到对应函数指针的位置
		_c_get_video_properties = (GetVideoInfoFunction)GetProcAddress(avp_dll, "c_get_video_properties");
		
		if (!_c_get_video_properties) {
			free_avp();
			return 2;
		}
		return 0;
	}

	char* c_get_video_properties(const char* filename, bool need_decode_data) {
		return _c_get_video_properties(filename, need_decode_data);
	}

#else
	#include "c_avp.h"
#endif
*/
import "C"

func (ffTool *FFmpegAvp) GetVideoDuration(inputVideoPath string, needDecodeData bool) (duration float64, err error) {
	cInputVideoPath := C.CString(inputVideoPath)
	cNeedDecodeData := C.bool(needDecodeData)

	defer func() {
		C.free(unsafe.Pointer(cInputVideoPath))
	}()
	result := make(map[string]interface{})
	strResult := C.c_get_video_properties(cInputVideoPath, cNeedDecodeData)
	err = json.Unmarshal([]byte(C.GoString(strResult)), &result)
	if err != nil {
		err = errors.New("视频属性解析失败")
		return
	}
	C.c_avp_free((*unsafe.Pointer)(unsafe.Pointer(&strResult)))
	duration = result["duration"].(float64) / 1000
	return
}

上面的方法和在C代码中使用动态加载库的方法一致,是手动使用C库方法来控制动态库,属于是在go中写c代码,当然也可以采用下面go已经封装好的方法来操作,相比上面的方式代码执行效率可能会低一些,但是项目结构性和可读性会强不少,而且都是go代码调试起来会很方便

使用syscall包来管理和操作DLL动态库

  • 加载 DLL:通过 syscall.LoadDLL 加载一个动态库,得到库的句柄。
  • 获取函数指针:通过 FindProc 获取指定函数的入口地址(函数指针)。
  • 调用函数:通过 Call 方法,调用函数指针来执行 DLL 中的函数。
  • 回调函数:通过syscall.NewCallbackCDecl获取适配C的函数指针,回调函数需要用//export注释导出
  • 释放库资源(可选):如果库不再需要,调用 Release()来释放 DLL。

加载动态库

syscall.LoadDLL(dyLibPath)

获取动态库接口函数

// dLib 的类型是 *syscall.DLL,返回的函数指针类型是 *syscall.Proc
dLib.libHandle.FindProc(funcName)

函数执行

// args ...uintptr
proc.Call(args...)

这里的函数返回值:(uintptr, uintptr, error) → (r1, r2, err)

  1. r1:主返回值

    对于大多数函数调用,r1 包含的就是函数的返回结果。比如,如果动态库函数返回一个整型值(如状态码或处理结果),这个值会存储在 r1 中。对于返回指针的函数,r1 是指向内存的指针值,指向结果数据的首地址。

  2. r2:次返回值

    r2 是辅助返回值,一般只在一些特定情况下使用。例如,当函数返回的是一个 64 位的值而当前系统架构是 32 位时,可能会将返回值分成两部分,通过 r1r2 返回。对于大多数常见的 32 位或单一返回值的函数,r2 通常为 0 或无用。

  3. err:错误码

    errsyscall.Errno 类型的错误码。可以通过 err.Error() 或直接检查 err 来获取错误描述。

返回值需要根据具体情况进行类型转换

e.g. 返回值是字符串的情况处理

ret, _, _ := meTool.RunProc(meTool.videoInfoProc, uintptr(unsafe.Pointer(cInputVideoPath)), uintptr(intNeedDecodeData))
cRet := (*C.char)(unsafe.Pointer(ret))
result = C.GoString(cRet)
defer meTool.Free(unsafe.Pointer(&cRet))

其中c返回的字符串指针,需要注意是否需要在go中释放,如果需要在go端释放就需要显式调用c的释放内存接口,避免出现内存泄漏问题。

两种方式的对比总结 #

特性 编译时链接动态库 运行时动态加载库
灵活性 较低,编译时确定库文件,编译的可执行程序直接依赖动态库 高,运行时决定加载哪个库,编译的可执行程序无需直接依赖动态库
使用复杂度 简单,直接调用库函数 复杂,需要手动加载库和绑定函数
库文件管理 程序启动时自动加载 手动加载和释放库文件
性能 启动时加载库,启动较快 运行时加载库,稍微影响启动时间
典型应用场景 常规程序依赖的第三方库 插件系统、动态库切换需求

这两种方式各有其优缺点,具体选择哪种方式取决于程序的需求。如果需要灵活地管理动态库并在运行时决定加载哪个库,运行时动态加载是更好的选择;而对于普通的库依赖,编译时链接动态库更简单方便。

小结 #

在使用 CGO 和动态库时,这些技巧和方法能够帮助开发者更加灵活地管理跨语言的代码交互,同时提供强大的可扩展性,但是也需要注意跨语言交互过程的内存安全等问题