Repository navigation
RFC: how should goffi fix linking with other cgo code under CGO_ENABLED=1 (#72)? #104
Description
We'd like input before deciding how to fix #72. Every option we found has a cost, and one of them would change something goffi has avoided so far: C code in goffi's own build (only in CGO_ENABLED=1 builds). This post tries to give the whole picture, so you don't need to have followed #13, #72 or #73.
Background: how goffi calls into libc
goffi is a pure-Go FFI library. To load a shared library and find its functions it needs four functions from the system C library: dlopen, dlsym, dlerror, and the function that returns the address of the thread's errno (__errno_location on Linux, __error on macOS and FreeBSD).
goffi gets them without cgo, the same way the Go runtime itself calls libc on macOS:
- a
//go:cgo_import_dynamicdirective tells the Go linker that a symbol, e.g.dlopen, comes from a shared library; - a small assembly stub jumps to that symbol;
- goffi calls the stub through
runtime.cgocall, and readserrnoright after the C call, on the same thread.
goffi supports two build modes:
CGO_ENABLED=0: no C compiler is involved; goffi'sinternal/fakecgoprovides the cgo runtime. This is the mode goffi was designed for, and the one to use for cross-compiling.CGO_ENABLED=1(supported since v0.5.1, PR feat: support CGO_ENABLED=1 alongside CGO_ENABLED=0 (closes #13) #37): requested in Discussion: Should goffi support CGO_ENABLED=1? #13 so that goffi can be used in programs that also use other cgo libraries (go-sqlite3, gocv, …) and with the race detector —go build -raceon Linux requires cgo.
Note that the Go toolchain enables cgo by default when it finds a C compiler (go env CGO_ENABLED prints 1 on a Linux machine with gcc). So on many Linux machines a plain go build uses CGO_ENABLED=1 unless you set it to 0.
Background: internal and external linking
The Go toolchain can link a program in two ways:
- internal linking: the Go linker writes the executable itself;
- external linking: the Go linker writes an object file and hands it to the system linker (gcc or clang).
With CGO_ENABLED=1, Go uses internal linking as long as all C code in the program comes from a few standard-library packages (runtime/cgo, net, os/user, the race runtime). As soon as any other package in the program has C code (any import "C", including purego's), or you ask for -ldflags=-linkmode=external, -buildmode=c-shared or -buildmode=c-archive, Go switches to external linking.
The problem (#72)
The Go linker refuses calls from Go code to //go:cgo_import_dynamic symbols when it links externally. The check is in cmd/link/internal/ld/data.go; only Darwin, OpenBSD, AIX and Solaris are exempt, because the Go runtime itself calls libc this way on those systems. With internal linking the same calls are turned into normal calls through the PLT before the check runs, which is why goffi on its own works.
So with CGO_ENABLED=1 on Linux and FreeBSD, goffi links only as long as nothing else in the program needs external linking. Otherwise:
dlopen_stub: unhandled relocation for goffi_dlopen (type 57 (SDYNIMPORT) rtype 7 (R_CALL))
dlsym_stub: unhandled relocation for goffi_dlsym (type 57 (SDYNIMPORT) rtype 7 (R_CALL))
dlerror_stub: unhandled relocation for goffi_dlerror (type 57 (SDYNIMPORT) rtype 7 (R_CALL))
goffi_errno_location_stub: unhandled relocation for goffi_errno_location (type 57 (SDYNIMPORT) rtype 7 (R_CALL))
Minimal reproducer (linux/amd64, Go 1.27.1, gcc 11.3): any program that calls goffi and also imports a package like this one fails to link with CGO_ENABLED=1:
package cpkg
// #include <unistd.h>
import "C"
func Getpid() int32 { return int32(C.getpid()) }What we found:
CGO_ENABLED=0 |
CGO_ENABLED=1 |
CGO_ENABLED=1, -linkmode=external |
|
|---|---|---|---|
| goffi alone | works | works | fails |
goffi + a package with import "C" (no purego) |
— | fails | fails |
| goffi + purego | fails: #22 (duplicate _cgo_init) |
fails | fails |
- It is not specific to purego (the title of
unhandled relocation ... SDYNIMPORTwhen goffi and ebitengine/purego are linked into the same binary #72 says purego because that is where it was found). - goffi v0.5.1, the first release with
CGO_ENABLED=1support, fails the same way. The main case thatCGO_ENABLED=1support was added for in Discussion: Should goffi support CGO_ENABLED=1? #13 — goffi together with other cgo libraries in one program — has never worked; our CI did not catch it because goffi's own tests link internally. - Platforms: reproduced on linux/amd64. For linux/arm64, freebsd/amd64 and freebsd/arm64 the same linker check fails when the Go linker is run directly in external mode; we'll confirm on real systems in CI.
- Not affected:
CGO_ENABLED=0builds and Windows. macOS should not be affected because Darwin is exempt; also to be confirmed in CI.
History, and a correction
- August 2026: @arturonaredo opened fix(linux): use cgo bridge for dynamic system symbols #73 with a fix using
import "C". We declined it because we did not want C code in goffi, and adopted a//go:linknameapproach suggested by @lkmavi instead (our review on fix(linux): use cgo bridge for dynamic system symbols #73 and our comment onunhandled relocation ... SDYNIMPORTwhen goffi and ebitengine/purego are linked into the same binary #72). @arturonaredo offered to implement that and asked two questions, which we never answered; he closed fix(linux): use cgo bridge for dynamic system symbols #73 in September. Sorry about that. - We have now tested the
//go:linknameapproach and it does not work://go:linkname libc_dlopen dlopenonvar libc_dlopen bytemakes the compiler define its own 1-byte symbol nameddlopen. Without purego in the program, the binary either crashes with SIGSEGV when it calls it, or fails to link when other C code in the program also callsdlopen. It looked fine in the original test only because purego was linked in: purego's own cgo package declaresdlopenas a C import. //go:cgo_import_staticwould solve it in the linker, but the compiler accepts it only in cgo-generated files or with-gcflags=<pkg>=-std, which every user would have to pass.
Upstream
- proposal: cmd/cgo: cgo without a C toolchain golang/go#81450, "proposal: cmd/cgo: cgo without a C toolchain", is in review. One change in that series, CL 839325, lifts this linker restriction on Linux. It is not merged and does not cover FreeBSD; if it is merged, Go 1.28 would be the earliest release with it, while goffi supports Go 1.25 and later. An earlier prototype, CL 828925, lifted it for all ELF systems including FreeBSD.
- So goffi needs its own fix for the Go versions it supports today.
Options
1. A small cgo package, built only with CGO_ENABLED=1, whose C code returns the addresses of the four libc functions. goffi keeps calling them exactly as now; only where the addresses come from changes. This is what purego does with cgo enabled (ebitengine/purego#197, after the same error in ebitengine/purego#189), what goffi's Android support already does, and close to #73.
- Prototype (linux/amd64, glibc): goffi + purego, goffi + another cgo package,
-linkmode=external,-linkmode=internal, PIE, c-shared, c-archive and-raceall build and run. goffi alone and goffi + purego also work on musl (Alpine). - What changes for you with
CGO_ENABLED=1: goffi contains a few lines of C, compiled by the C compiler you already need forruntime/cgo; goffi's builds use the external linker by default (no measurable difference in our link-time measurements;-linkmode=internalkeeps working). - Nothing changes with
CGO_ENABLED=0.
2. Prebuilt .syso objects: a ~1 KB object file per target that contains jmp dlopen@PLT, no C source.
- Prototype (linux/amd64): works with external and internal linking, PIE and static builds.
- What changes for you: binary objects in the module; every
CGO_ENABLED=1build links externally;go build -msandrops.sysofiles, so that mode would break; on build hosts with glibc older than 2.34 you would have to setCGO_LDFLAGS=-ldlyourself.
3. Find dlopen at run time in pure Go, by walking the dynamic loader's list of loaded libraries (auxv → r_debug → link_map → symbol table).
- Prototype (linux/amd64, 187 lines): works in every dynamic link mode, including on glibc 2.31.
- What changes for you: nothing visible, but goffi would carry an estimated 500–900 lines of code that depends on dynamic-loader internals; on glibc older than 2.34 it needs the private glibc function
__libc_dlopen_mode; FreeBSD needs its own handling.
4. Wait for upstream and document the workarounds. goffi with purego under CGO_ENABLED=1, c-shared and c-archive stay broken until goffi's minimum Go version contains the linker change; FreeBSD has no upstream change at all.
We also looked at importing the plugin package (it disables the check, but roughly doubled the binary size in our test and no longer helps on freebsd/arm64 since Go 1.26) and at other relocation tricks (they fail).
Whichever option is chosen, it is meant as a bridge: we plan to remove it once the Go toolchain makes it unnecessary (CL 839325 for Linux, the binding mode of golang/go#81450 in general), and will track that in a separate issue.
Nothing is decided. Option 1 looks the most robust to us, but we want to hear from the people who build goffi with CGO_ENABLED=1 before changing what goffi ships.
Questions
- If you build goffi with
CGO_ENABLED=1, why: race detector, other cgo libraries, c-shared, something else? - Would a few lines of C, compiled only in
CGO_ENABLED=1builds, be a problem for you? Would prebuilt objects (option 2) be more or less acceptable? - Do you build on hosts with glibc older than 2.34, on FreeBSD, or on macOS with
CGO_ENABLED=1? - Is there an approach we missed?
Workarounds today
- goffi + purego:
CGO_ENABLED=0 go build -tags nofakecgo ./...(the link: duplicated definition of symbol _cgo_init #22 workaround). - goffi + other cgo packages (not purego):
go build -ldflags=-linkmode=internal ./....
cc @unxed (#72) @arturonaredo (#73) @lkmavi @besmpl @jiyeyuran (#37) @pjebs (#13)
Activity
kolkov commentedon Oct 17, 2026
CI results for the points above that we said CI would confirm. They come from a branch that adds only tests; goffi's code is unchanged (run 39033802869, Go 1.25 and the current stable Go):
ubuntu-24.04-armrunner): fails the same way as amd64, withrtype 9 (R_CALLARM64)instead ofR_CALL.CGO_ENABLED=0,CGO_ENABLED=1with the default link mode and with-linkmode=internalpass.-linkmode=external, and a test program that links goffi together with purego and a cgo package, fail with the same error.-linkmode=externaland-linkmode=internal, a program with goffi, purego and a cgo package builds and runs with the default and the external link mode, and a library using goffi builds with-buildmode=c-sharedandc-archive.windows-latest): the tests pass with external and internal linking.CGO_ENABLED=0 go build -tags nofakecgo, works on Linux (amd64 and arm64), FreeBSD and macOS.A second run with the same tests (run 39034696002) gave the same results and also shows the tests passing with
-linkmode=internalon Linux (amd64 and arm64), so only the external link mode is broken there.The tests themselves are
go test -ldflags=-linkmode=externaland-linkmode=internalforCGO_ENABLED=1, plus a separate module undertestdata/linktestthat links goffi with purego and a plain cgo package with the default and the external link mode, and builds a library using goffi with-buildmode=c-sharedandc-archive(purego does not become a goffi dependency). Whatever we decide here, they will stay in CI, so this cannot go unnoticed again.The questions in the post above are still open — input from anyone building goffi with
CGO_ENABLED=1is welcome.