最近把一个持续开发中的项目整理到了可以公开试用的阶段:C2Go Toolchain。
项目地址:https://github.com/c2gohq/c2go_toolchain
中文文档:https://c2go.buymecompile.top/zh-cn/
当前预发布版本:v0.20260802.0-rc.1
先用一句话说明它在做什么:
C2Go 基于 Clang/LLVM ,把 C 源码提前编译成可由 Go 工具链直接构建的
.go + .spackage ;生成结果不使用import "C",使用方可以在CGO_ENABLED=0、没有宿主 C 编译器的环境中完成构建。
它不是把 C 文本逐句改写成 Go 源码,也不是在应用启动后嵌入一个 C 编译器。C 的编译发生在生成阶段,最终产物进入普通的 Go build 流程。
为什么做这个项目
Go 调用已有 C 实现时,最成熟的方案当然还是 cgo 。但我想探索的是另一条路径:
- 能不能继续使用 Clang/LLVM 的 C 前端和优化能力;
- 能不能把结果变成 Go package ,而不是在最终构建时再次调用 C toolchain ;
- 能不能让生成代码运行在 Go runtime 下,使用 goroutine 栈、Go ABI 和 GC 可见的内存;
- 能不能让库的最终使用者在
CGO_ENABLED=0的环境中构建。
C2Go 就是围绕这些目标做的一套编译器、绑定生成器和 libc 兼容层。
它具体生成什么
目前的管线大致如下:
C 源码
│
▼
c2go-clang + c2go-lto
│ Plan 9 汇编 + JSON manifest
▼
c2go-bind
│ 生成 .go + .s
▼
普通 Go package
│
▼
go build / go test (可使用 CGO_ENABLED=0 )
几个主要仓库的分工是:
c2go-clang:基于 LLVM/Clang 的 C2Go 编译模式和 LTO 工具;c2go-bind:读取汇编与 manifest ,生成 Go 声明、ABI glue 和 package 文件;c2go-libc:面向生成代码的 libc/runtime 兼容层,并使用 PureGo 处理需要的原生调用边界;c2go-toolchain:锁定各组件版本并统一发布 SDK 。
一个最小例子
先下载与当前系统匹配的 SDK 、把 bin 加入 PATH。下面以 macOS arm64 为例,其他平台只需要换成对应的 target triple 。
mkdir hello-c2go
cd hello-c2go
mkdir .c2go
go mod init example.com/hello-c2go
go get github.com/c2gohq/[email protected]
export C2GO_TARGET=aarch64-apple-darwin
创建 .c2go/input.c:
#include <stdint.h>
#include <c2go.h>
c2go_extern int add(int a, int b) {
return a + b;
}
编译 C ,并生成汇编和 manifest:
c2go-clang --target="$C2GO_TARGET" \
-fc2go \
-fc2go-package=example.com/hello-c2go/translated \
-O2 \
-fc2go-emit-plan9-asm=.c2go/translated.s \
-fc2go-emit-manifest=.c2go/translated.json \
.c2go/input.c
生成 Go package:
mkdir -p translated
c2go-bind \
--out=translated \
--sidecar=.c2go/translated.json \
.c2go/translated.s
创建 main.go:
package main
import (
"fmt"
"example.com/hello-c2go/translated"
)
func main() {
fmt.Println(translated.Add(20, 22))
}
现在可以直接交给 Go 工具链:
CGO_ENABLED=0 go run .
输出:
42
这里的 c2go_extern 表示把已经定义的 C 函数导出给 Go 。C 的 int 仍然是 32 位,因此生成的实际签名是 func Add(a, b int32) int32;示例中的无类型常量可以直接传入。
和普通 C 不太一样的地方
C2Go 需要同时面对 C 的内存模型和 Go runtime ,因此明确区分 managed 与 unmanaged 数据:
- managed 对象可以带有精确的 GC 类型信息,并分配在 Go heap ;
- unmanaged 对象用于原生 C 内存、系统库或其他 C ABI 边界;
gc_malloc、c2go_typeinfo、c2go_linkname、callback/callout 等扩展用来描述两边的类型、所有权和调用关系。
这部分不是为了创造一门全新的 C 方言,而是把原本藏在 cgo glue 或人工约定里的边界信息明确交给编译器检查。
对普通使用场景,可以在需要 Go managed 语义的区域使用:
#pragma c2go managed push
/* managed C code */
#pragma c2go managed pop
无参数的 managed 默认启用全部 managed 能力;只有需要精细控制时才使用更具体的属性。
目前做到什么程度了
v0.20260802.0-rc.1 已发布以下四种 SDK:
| 系统 | 架构 | Target triple |
|---|---|---|
| Linux | amd64 | x86_64-unknown-linux-goabi |
| Linux | arm64 | aarch64-unknown-linux-goabi |
| Windows | amd64 | x86_64-pc-windows-goabi |
| macOS | arm64 | aarch64-apple-darwin |
当前版本对应 Go 1.25.x 。项目已经用一份经过适配的 musl 生成 c2go-libc ,并持续用 Go 测试覆盖字符串、内存、数学、文件 I/O 、动态加载等路径。
这仍然是 pre-1.0 的 RC 版本,更适合尝鲜、移植实验和帮助发现边界问题,暂时不建议不经审计就直接用于生产系统。
先把最重要的限制讲清楚
当前 C2Go 不会像 Go 编译器一样,把发生逃逸的 C 栈局部变量自动提升到堆。
如果一个局部变量的地址被保存进堆对象、全局变量、异步任务或其他会长期持有它的状态,那么函数返回或 goroutine 栈移动后,这个地址可能失效。同步调用链中,在对象生命周期内传递地址且 callee 不保留它,并不等于逃逸;问题在于指针是否可能比所属栈对象活得更久。
因此,准备发布的代码需要把独立的 c2go-lto 全程序逃逸审计设成门禁;确实需要长期保留的数据,应按所有权放入带正确类型信息的 Go heap ,或放入相应 native allocator 管理的内存。普通编译成功本身不能替代这项审计。
此外,当前还有这些明确边界:
- 只支持 C 和 64 位目标,不支持 C++;
- VLA 和动态
alloca会被拒绝; - 一些复杂 record 、callback 和多返回值 ABI 形状会 fail closed ;
- Go 1.26 暂不在当前 ABI 兼容窗口;
- macOS 和 Windows 的预发布二进制目前尚未签名。
开源协议
- c2go-clang 延续 LLVM 的
Apache-2.0 WITH LLVM-exception; - c2go-bind 的原创部分使用
AGPL-3.0-only,也可以另行取得商业许可; - c2go-libc 同时包含 C2Go 原创代码、musl 派生代码和其他第三方材料,各部分保留各自协议与声明。
具体权利边界以每个仓库中的 LICENSE、NOTICE 和文件头为准。
想听听 V2EX 各位的意见
目前我最想收集的不是“它能不能替代所有 cgo”——现阶段答案显然是否定的——而是真实 C 项目带来的问题:
- 哪些 Makefile/CMake 构建方式最需要更顺滑的接入;
- 哪些 callback 、struct 、函数指针或系统库边界最容易卡住;
- 目前的命令行和诊断是否足够容易理解;
- 文档中哪些概念对有 C 经验、但不了解 Go ABI 的人仍然太绕。
如果你手上有一个体量适中、测试比较完整的纯 C 库,也欢迎拿它试一下。遇到编译、ABI 或文档问题,可以直接在对应仓库提 issue 。
相关链接:
- 官网与中文文档:https://c2go.buymecompile.top/zh-cn/
- Hello World:https://c2go.buymecompile.top/zh-cn/docs/hello-world/
- GitHub 组织:https://github.com/c2gohq
- Toolchain Release:https://github.com/c2gohq/c2go_toolchain/releases/tag/v0.20260802.0-rc.1