在需要和 Windows 系统深度集成,或给 C/C++、Python、C# 这类语言提供可调用模块时,把 Go 代码编译成 DLL 是一条很实用的路径。关键不只是跑通 go build -buildmode=c-shared,还要弄清楚导出函数怎么写、哪些类型能安全暴露、字符串内存该由谁释放。下面按“环境检查、最小示例、规则约束、字符串案例”的顺序展开,便于你快速判断自己的项目是否适合这样做。
先把环境准备齐:Go 和 GCC 都要可用
在 Windows 下用 Go 生成 DLL,先决条件并不复杂,但少任何一项都会直接卡在编译阶段。
首先需要安装 Go 开发环境;其次要有 GCC 编译器。Go 导出 C 可调用符号时依赖 C 编译工具链,常见选择是 MinGW-w64。安装完成后,还要把 GCC 所在目录加入系统的 PATH 环境变量。
可以先用下面两条命令做最基本的检查:
go version
gcc --version
如果两条命令都能正常输出版本信息,说明构建 DLL 所需的基础环境已经就绪。对于刚接触这一流程的读者来说,这一步的意义在于尽早排除工具链问题,避免把编译报错误判成 Go 代码本身的问题。
第一个 Go DLL:最小示例怎么写
Go 提供了 buildmode 选项来支持构建动态链接库。在实际使用中,最常见的模式就是 c-shared,它会生成一个可供其他语言调用的 Windows DLL。

下面用一个简单的数学库示例演示完整写法。先创建 mathutil 目录,再写入 math.go:
package main
import "C"
import (
"math"
)
//export Add
func Add(a, b int) int {
return a + b
}
//export Multiply
func Multiply(a, b float64) float64 {
return a * b
}
//export SquareRoot
func SquareRoot(n float64) float64 {
return math.Sqrt(n)
}
func main() {}
这个例子里有几个容易忽略、但必须满足的点。
为什么必须有 import "C"
Go 的 DLL 导出能力建立在 cgo 机制之上,因此文件中必须出现 import "C"。没有这一句,后面的导出注释和 c-shared 构建模式就不会按预期工作。
//export 注释的作用
导出函数不是靠函数名首字母大写完成的,而是依赖 //export 注释。只有被明确标记的函数,才会出现在最终生成的 DLL 导出符号中,供外部程序调用。

为什么还要保留空的 main 函数
这里的包名必须是 main,同时还要保留一个空的 main 函数作为入口。它本身不会导出给外部使用,但缺少它通常会导致构建形式不符合 c-shared 的要求。
代码写好后,直接执行构建命令:
go build -buildmode=c-shared -o mathutil.dll math.go
编译完成后,通常会得到两个关键文件:
mathutil.dll:真正的动态链接库文件;mathutil.h:配套生成的 C 头文件,里面包含导出函数声明。
这也是 Go DLL 和普通可执行文件最大的区别之一:除了二进制本体,还会顺带给出一个供 C/C++ 集成时使用的头文件。对于需要在其他语言项目中接入 Go 能力的场景,这个头文件就是最直接的接口说明。
导出函数有哪些硬规则
能成功编译出 DLL,只说明第一步完成了;想让其他语言稳定调用,还得遵守一组更重要的接口规则。这里最需要关心的就是类型兼容、内存管理和并发安全。
返回值和参数尽量使用 C 兼容类型
外部语言并不了解 Go 运行时,也不能自然处理 Go 的复杂类型。因此,导出函数最好只暴露 C 兼容的基础类型,比如 int、float64、*C.char 这类可以清晰映射到 C 接口的形式。
如果接口里直接混入 Go 专有结构,调用端往往很难正确处理,轻则调用麻烦,重则直接造成崩溃或未定义行为。尤其是字符串场景,更适合通过 C 字符串指针来传递结果,而不是试图把 Go 字符串原样暴露出去。
Go 分配的对象不能随意交给 C 侧长期使用
内存问题是 DLL 场景里最常见的坑之一。由 Go 管理的字符串或其他对象,不能简单返回给 C 代码后让对方长期持有。更稳妥的做法,是在 Go 中分配 C 可管理的内存,或者让调用方先分配缓冲区再传入。
这条规则的本质是:接口边界一旦跨到 C 世界,内存归属必须说清楚。谁申请,谁释放;在哪个运行时下分配,就尽量在哪个运行时下回收。否则,泄漏和非法访问都很容易出现。
多线程调用时要考虑并发安全
如果 DLL 可能被多个线程同时调用,导出的 Go 函数就必须保证线程安全。尤其当函数内部读写共享状态时,更要明确同步策略。
原文里还特别提醒了一点:更建议在 DLL 初始化阶段创建 Goroutine,而不是在每个导出函数里临时创建。这类约束并不是语法层面的硬错误,但在真实项目中会明显影响稳定性和调用行为,值得在接口设计阶段就提前考虑。
更实用的例子:字符串处理 DLL
数学计算示例更适合说明构建流程,而真正进入跨语言调用时,字符串处理往往更能暴露接口设计的细节。下面这个例子展示了一个更贴近实际的字符串工具库写法:
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"strings"
"unsafe"
)
//export ToUpperCase
func ToUpperCase(s *C.char) *C.char {
return C.CString(strings.ToUpper(C.GoString(s)))
}
//export FreeMemory
func FreeMemory(p unsafe.Pointer) {
C.free(p)
}
// ReverseString、ConcatStrings 等函数的实现类似
// 使用 C.GoString 转换输入,C.CString 返回结果
func main() {}
这个案例虽然不长,但把 Go DLL 最关键的字符串处理逻辑基本都带出来了。
输入参数:先把 C 字符串转成 Go 字符串
ToUpperCase 接收的是 *C.char,进入函数后先通过 C.GoString(s) 转成 Go 可处理的字符串,再交给 strings.ToUpper 做业务逻辑。这是跨语言字符串输入最常见的一步。
返回结果:用 C.CString 构造可交给外部的字符串
处理结果不能直接把 Go 字符串返回出去,而是要通过 C.CString 生成 C 侧可识别的字符串指针。这样调用方拿到的是标准 C 风格字符串,更容易在 C/C++、Python 或 C# 封装层中处理。
为什么必须提供 FreeMemory
一旦使用 C.CString 创建返回值,就意味着这块内存需要显式释放。因此示例里额外导出了 FreeMemory:
func FreeMemory(p unsafe.Pointer) {
C.free(p)
}
这一点很关键。调用方在使用完返回的字符串后,需要主动调用 FreeMemory 回收内存,否则很容易造成泄漏。对于字符串反转、拼接、大小写转换这类频繁调用的接口,如果不把释放约定设计清楚,问题通常会在运行一段时间后才暴露出来。
这个字符串 DLL 的编译方式与前面的数学示例一致:
go build -buildmode=c-shared -o stringutil.dll main.go
也就是说,真正变化的不是构建命令,而是导出函数的签名设计和内存处理方式。很多项目第一次做 Go DLL 时,编译阶段并不难,难的是把接口收敛成一个外部语言容易接、又不容易出错的 C 风格边界。
适合哪些场景,关键判断点是什么
把 Go 编译成 Windows DLL,核心方法并不复杂:使用 //export 标记导出函数,保持接口类型尽量 C 兼容,再通过 go build -buildmode=c-shared 生成 DLL 和对应头文件。
这条路线特别适合几类场景:一是给 Python、C++、C# 等现有项目补充高性能模块;二是把 Go 中已经写好的业务逻辑封装成可复用组件;三是在保留原有系统主体不变的前提下,用 DLL 方式逐步引入 Go 的实现能力。
但在真正落地前,最好先判断三件事:你的接口能否收敛成基础 C 类型、字符串或缓冲区的内存归属能否说清楚、调用方是否存在多线程并发访问。如果这三点都能设计明白,Go 写 Windows DLL 通常是可行且成本不高的方案。









