通过 cgo 调用 Rust
约 2221 字大约 7 分钟
2026-04-19
概述
cgo 是 Go 提供的 FFI(Foreign Function Interface)机制,允许 Go 代码调用 C 函数、C 代码回调 Go 函数。由于 C ABI 是事实上的跨语言通用接口,任何能编译为 C 兼容静态库的语言(Rust、C++、Zig 等)都可以通过 cgo 与 Go 互操作。
本文以 Go 调用 Rust 为例,介绍 cgo 的使用方式。
基本示例:Go 调用 Rust
目录结构
cgo - main.go - my_rust - Cargo.toml - my_rust.h - src - lib.rs - target/release - libmy_rust.a // (cargo build 产出)
Rust 侧
编写一个 Rust 静态库,导出 C ABI 函数:
my_rust/src/lib.rs
use std::ffi::c_long;
#[unsafe(no_mangle)]
pub extern "C" fn add(a: c_long, b: c_long) -> c_long {
a + b
}extern "C":使用 C 调用约定,保证与 Go/C 的 ABI 兼容#[unsafe(no_mangle)]:禁止 Rust 的符号名修饰(name mangling),确保链接时符号名就是add。这是 Rust 2024 edition 的写法,2021 及之前的版本使用#[no_mangle]c_long:使用std::ffi::c_long匹配 C 的long类型,避免在不同平台上因类型宽度不一致导致 ABI 不匹配
my_rust/Cargo.toml
[package]
name = "my_rust"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["staticlib"]crate-type = ["staticlib"]告诉 Rust 编译器生成静态库(.a文件),而不是默认的 rlib。
my_rust/my_rust.h
#ifndef MY_RUST_H
#define MY_RUST_H
long add(long a, long b);
#endif- C 头文件声明函数签名,供 cgo 的 C 编译器使用。
编译 Rust 库:
cd my_rust && cargo build --release
# 产出:target/release/libmy_rust.aGo 侧
main.go
package main
/*
#cgo CFLAGS: -I${SRCDIR}/my_rust
#cgo darwin LDFLAGS: -L${SRCDIR}/my_rust/target/release -lmy_rust
#cgo linux LDFLAGS: -L${SRCDIR}/my_rust/target/release -lmy_rust -lm -ldl -lpthread
#include "my_rust.h"
*/
import "C"
import "fmt"
func main() {
result := C.add(3, 4)
fmt.Println("3 + 4 =", result)
}几个关键点:
import "C"是一个伪导入,紧跟其上方的注释块被称为 preamble(序言),会被 cgo 提取并传递给 C 编译器#cgo CFLAGS:传递给 C 编译器的参数,-I指定头文件搜索路径#cgo LDFLAGS:传递给链接器的参数,-L指定库搜索路径,-lmy_rust链接libmy_rust.a${SRCDIR}会被替换为当前 Go 源文件所在的绝对路径#cgo darwin LDFLAGS/#cgo linux LDFLAGS:可以按平台设置不同的编译/链接参数- Linux 上 Rust 静态库额外依赖
-lm -ldl -lpthread(数学库、动态链接库、POSIX 线程库)
运行:
go run main.go
# 输出:3 + 4 = 7Rust 回调 Go
通过 //export 指令可以将 Go 函数导出,供 C/Rust 侧回调。
Go 侧:
package main
/*
#cgo CFLAGS: -I${SRCDIR}/my_rust
#cgo darwin LDFLAGS: -L${SRCDIR}/my_rust/target/release -lmy_rust
#cgo linux LDFLAGS: -L${SRCDIR}/my_rust/target/release -lmy_rust -lm -ldl -lpthread
#include "my_rust.h"
*/
import "C"
import "fmt"
//export GoCallback
func GoCallback(x C.long) C.long {
fmt.Printf("Go received: %d\n", int64(x))
return x * 2
}
func main() {
result := C.call_go(10)
fmt.Println("Result:", result)
}Rust 侧:
use std::ffi::c_long;
// unsafe extern "C" 是 Rust 2024 edition 的写法
unsafe extern "C" {
fn GoCallback(x: c_long) -> c_long;
}
#[unsafe(no_mangle)]
pub extern "C" fn call_go(x: c_long) -> c_long {
unsafe { GoCallback(x) }
}头文件:
#ifndef MY_RUST_H
#define MY_RUST_H
long add(long a, long b);
long call_go(long x);
#endif注意:使用了
//export的 Go 文件中,preamble 里不能包含 C 函数定义,只能包含声明。如果需要内联 C 代码,应放在另一个不含//export的 Go 文件中。
类型映射
基本类型
cgo 会将 C 类型映射为对应的 Go 类型:
| C 类型 | Go 类型 | Rust 对应类型 |
|---|---|---|
char | C.char(平台相关) | c_char |
unsigned char | C.uchar (uint8) | c_uchar |
int | C.int (int32) | i32 |
unsigned int | C.uint (uint32) | u32 |
long | C.long (平台相关) | c_long(见下方说明) |
float | C.float (float32) | f32 |
double | C.double (float64) | f64 |
const void* | unsafe.Pointer | *const c_void |
void* | unsafe.Pointer | *mut c_void |
const char* | *C.char | *const c_char |
char* | *C.char | *mut c_char |
size_t | C.size_t | usize |
struct T | C.struct_T | #[repr(C)] struct T |
enum E | C.enum_E | #[repr(C)] enum E |
C 的
long在不同平台上大小不同:64-bit Linux/macOS 上是 64 位,但 32-bit 平台和 Windows 64-bit 上是 32 位。Rust 侧如果固定用i64会导致 ABI 不匹配。推荐使用std::ffi::c_long来匹配 C 的long,或者 C 和 Rust 两侧都改用固定宽度类型(int64_t/i64)来避免歧义。
字符串转换
Go 的 string 和 C 的 char* 内存模型完全不同(Go 字符串不以 \0 结尾),需要显式转换:
// Go string → C string(malloc 分配,需手动释放)
cStr := C.CString("hello")
defer C.free(unsafe.Pointer(cStr))
// C string → Go string(拷贝数据,无需管理 C 内存)
goStr := C.GoString(cStr)
// C 数据 → Go []byte(指定长度拷贝)
goBytes := C.GoBytes(unsafe.Pointer(cData), C.int(length))
C.CString内部调用C.malloc分配内存,必须手动调用C.free释放,否则会内存泄漏。
结构体
跨语言传递结构体时,Rust 侧必须使用 #[repr(C)] 保证 C 内存布局,否则 Rust 编译器可能重排字段顺序或使用不同的对齐方式,导致 Go 侧读取到错误的数据:
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[unsafe(no_mangle)]
pub extern "C" fn distance(p1: *const Point, p2: *const Point) -> f64 {
unsafe {
let dx = (*p1).x - (*p2).x;
let dy = (*p1).y - (*p2).y;
(dx * dx + dy * dy).sqrt()
}
}头文件中声明结构体时,有两种写法,Go 侧的引用方式不同:
方式一:命名结构体 + typedef
typedef struct Point {
double x;
double y;
} point;
double distance(const point* p1, const point* p2);Go 侧可以用 C.struct_Point(结构体名)或 C.point(typedef 名):
// 通过 struct 名引用
p1 := C.struct_Point{x: 0.0, y: 0.0}
// 通过 typedef 名引用
p2 := C.point{x: 3.0, y: 4.0}
d := C.distance(&p1, &p2)
fmt.Println("distance:", d) // 5.0方式二:纯 typedef(匿名结构体)
typedef struct {
double x;
double y;
} point;
double distance(const point* p1, const point* p2);Go 侧只能用 C.point(没有结构体名,不存在 C.struct_xxx):
p1 := C.point{x: 0.0, y: 0.0}
p2 := C.point{x: 3.0, y: 4.0}
d := C.distance(&p1, &p2)
fmt.Println("distance:", d) // 5.0字符串和切片
Rust 的 String / &str 和 Vec<T> / &[T] 不能直接跨 FFI 传递,通常传递裸指针 + 长度:
#[unsafe(no_mangle)]
pub extern "C" fn process_bytes(ptr: *const u8, len: usize) -> i32 {
let data = unsafe { std::slice::from_raw_parts(ptr, len) };
data.iter().map(|&b| b as i32).sum()
}data := []byte{1, 2, 3, 4, 5}
result := C.process_bytes(
(*C.uchar)(unsafe.Pointer(&data[0])),
C.size_t(len(data)),
)指针传递规则
cgo 对 Go 指针的传递有严格限制,由运行时动态检查(GODEBUG=cgocheck=1):
| 规则 | 说明 |
|---|---|
| Go 指针可以传递给 C | 但 C 不能在调用返回后继续持有该指针 |
| C 指针可以自由传递给 Go | 无限制 |
| Go 指针指向的内存中不能包含其他 Go 指针 | 传递的结构体字段中不能有指向 Go 堆的指针 |
同步调用(C 函数执行完就返回)不需要额外处理,cgo 会保证指针在调用期间有效。但如果 C/Rust 侧需要在函数返回后继续持有 Go 指针(如异步回调、后台线程处理),需要使用 runtime.Pinner(Go 1.21+)将对象钉住,防止 GC 移动或回收:
data := []byte("hello from Go")
var pinner runtime.Pinner
pinner.Pin(&data[0])
// C/Rust 侧将指针保存下来,在后台线程中异步处理
C.async_process((*C.char)(unsafe.Pointer(&data[0])), C.int(len(data)))
// 必须等异步处理完成后才能 Unpin
// ...
pinner.Unpin()内存管理
Go 和 C/Rust 各自管理自己的内存,跨语言传递时需要明确谁负责释放:
| 场景 | 规则 |
|---|---|
| Go 分配,传给 C/Rust | C/Rust 不能在函数返回后继续持有(除非使用 Pinner) |
C malloc 分配,传给 Go | Go 侧必须调用 C.free 释放 |
| Rust 分配,传给 Go | Go 侧必须回调 Rust 的释放函数 |
使用注意事项
性能开销
每次 cgo 调用涉及栈切换、调度器通知等操作,单次开销约 50~100ns(纯 Go 函数调用约 1~2ns)。应避免在热路径上高频调用,尽量批量处理:
// ❌ 高频调用:开销被放大
for i := 0; i < 1000000; i++ {
C.add(C.long(i), C.long(1))
}
// ✅ 批量处理:减少跨语言调用次数
data := make([]C.long, 1000000)
C.batch_add((*C.long)(unsafe.Pointer(&data[0])), C.int(len(data)))线程占用
cgo 调用期间会占用一个 OS 线程(不受 GOMAXPROCS 限制),大量并发的 cgo 调用会导致线程数暴增。可以用信号量限制并发:
var sem = make(chan struct{}, runtime.NumCPU())
func callRust(x int) int {
sem <- struct{}{}
defer func() { <-sem }()
return int(C.heavy_compute(C.int(x)))
}交叉编译
cgo 需要目标平台的 C 编译器工具链,Rust 库还需要对应的 target:
# 交叉编译 Rust 库(以 Linux x86_64 为例)
rustup target add x86_64-unknown-linux-gnu
cargo build --release --target x86_64-unknown-linux-gnu
# 交叉编译 Go(需要对应的 C 交叉编译器)
CGO_ENABLED=1 CC=x86_64-linux-gnu-gcc \
GOOS=linux GOARCH=amd64 \
go build -o app-linux main.go如果不需要 cgo,设置 CGO_ENABLED=0 可以生成纯静态的 Go 二进制,避免对 C 工具链的依赖。
何时使用 cgo
引入 cgo 的代价:
- 编译速度变慢(需要 C 编译器参与)
- 交叉编译变得复杂
- 二进制文件可能动态依赖系统 libc(除非使用 musl 等静态工具链)
- 调试难度增加(Go 和 C/Rust 的调试器不互通)
- 函数调用开销增加
适合使用 cgo 的场景:
- 需要调用成熟的 C/Rust 库(如密码学、图像处理、机器学习推理)
- 对性能要求极高的计算密集型代码(利用 Rust 的零成本抽象和 SIMD)
- 需要与系统底层接口交互(如特定操作系统 API)
