Skip to content

RFC: how should goffi fix linking with other cgo code under CGO_ENABLED=1 (#72)? #104

Open

Description

@kolkov

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_dynamic directive 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 reads errno right after the C call, on the same thread.

goffi supports two build modes:

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 ... SDYNIMPORT when 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=1 support, fails the same way. The main case that CGO_ENABLED=1 support 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=0 builds and Windows. macOS should not be affected because Darwin is exempt; also to be confirmed in CI.

History, and a correction

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 -race all 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 for runtime/cgo; goffi's builds use the external linker by default (no measurable difference in our link-time measurements; -linkmode=internal keeps 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=1 build links externally; go build -msan drops .syso files, so that mode would break; on build hosts with glibc older than 2.34 you would have to set CGO_LDFLAGS=-ldl yourself.

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

  1. If you build goffi with CGO_ENABLED=1, why: race detector, other cgo libraries, c-shared, something else?
  2. Would a few lines of C, compiled only in CGO_ENABLED=1 builds, be a problem for you? Would prebuilt objects (option 2) be more or less acceptable?
  3. Do you build on hosts with glibc older than 2.34, on FreeBSD, or on macOS with CGO_ENABLED=1?
  4. Is there an approach we missed?

Workarounds today

cc @unxed (#72) @arturonaredo (#73) @lkmavi @besmpl @jiyeyuran (#37) @pjebs (#13)

Activity

  1. added this to the v0.6.7 milestone on Oct 17, 2026
  2. kolkov commented on Oct 17, 2026

    @kolkov
    ContributorAuthor

    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):

    • linux/arm64 (native ubuntu-24.04-arm runner): fails the same way as amd64, with rtype 9 (R_CALLARM64) instead of R_CALL.
    • FreeBSD 14.5 and 15.1 (amd64 VM; freebsd/arm64 is not covered by this run): CGO_ENABLED=0, CGO_ENABLED=1 with the default link mode and with -linkmode=internal pass. -linkmode=external, and a test program that links goffi together with purego and a cgo package, fail with the same error.
    • macOS (Apple Silicon and Intel): not affected. The tests pass with -linkmode=external and -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-shared and c-archive.
    • Windows (windows-latest): the tests pass with external and internal linking.
    • The workaround for goffi + purego, 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=internal on Linux (amd64 and arm64), so only the external link mode is broken there.

    The tests themselves are go test -ldflags=-linkmode=external and -linkmode=internal for CGO_ENABLED=1, plus a separate module under testdata/linktest that 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-shared and c-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=1 is welcome.

  3. modified the milestones: v0.6.7, v0.6.8 on Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions