You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Anything that imports goffi cannot be linked statically #74
so the Go internal linker records an ELF interpreter and DT_NEEDED entries even under CGO_ENABLED=0. Anything that ends up importing goffi comes out dynamically linked:
This is easy to miss, since CGO_ENABLED=0 normally implies a static binary. Forcing it doesn't help either:
$ go build -ldflags="-linkmode=internal -d"
link: cannot use dynamic imports with -d flag
dlopen_stub: relocation target goffi_dlopen not defined
This came up in unxed/f4#693 — a Go file manager that pulls goffi in through gogpu/wgpu and through purego. Its linux/amd64 and linux/arm64 release binaries turn out not to be static, so they don't start on Alpine/musl or in a FROM scratch image. Every architecture where goffi isn't compiled in is static.
This is separate from #72 and #73: those are relocation failures in cgo builds. Here the build succeeds, the binary just isn't static.
dlopen and full static linking are of course fundamentally at odds — a host .so can't be loaded without a loader. So the real question is whether static linking is something goffi wants to support at all. If it isn't, could that go in the README? Right now a downstream project has no easy way to notice that pulling in goffi makes its binaries dynamic.
Good catch, and you're right that this should be documented.
The short answer: //go:cgo_import_dynamic unconditionally adds PT_INTERP and DT_NEEDED to the ELF binary — there's no way around this at the Go linker level. This isn't goffi-specific: purego has the identical behavior (dlfcn_nocgo_linux.go imports from libdl.so.2 the same way), and their maintainer confirmed Alpine/musl doesn't work (purego#371). Rust's libloading crate has the same constraint — static musl binaries can't call dlopen.
The reason is architectural: dlopen is a function inside ld-linux.so.2 (the ELF dynamic linker). The kernel only loads ld.so into a process when PT_INTERP is present. No PT_INTERP → no ld.so → no dlopen. This goes back to the 1988 SunOS design decision to keep the dynamic linker entirely in userspace. Windows took the opposite approach (ntdll.dll always loaded), which is why LoadLibrary works from any binary there.
For Alpine specifically: musl-linked dynamic binaries work fine. Build on Alpine or with CC=musl-gcc:
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache gcc musl-dev
COPY . .
RUN go build -o myapp ./cmd/myapp
FROM alpine:3.20
COPY --from=builder /app/myapp /myapp
The binary is dynamic but against musl's ld-musl-x86_64.so.1, which exists on Alpine.
For FROM scratch: not possible with dlopen — the dynamic linker isn't present.
We'll add this to the README. Long-term, we're exploring a pure Go ELF loader that would eliminate the dlopen dependency — early stage research, no timeline yet.
The //go:cgo_import_dynamic directives recording DT_NEEDED entries even under CGO_ENABLED=0 is a subtle trap — it silently defeats the expectation of a static binary, and the -d workaround just shifts the failure to a relocation error.
I've been working on machin (github.com/javimosch/machin), a single-binary Go/WASM build and distribution toolchain that can take Go projects and produce tiny static binaries or WASM modules, sidestepping linker quirks like this.
One question: for the f4 use case, is the goal a fully static ELF for distribution, or would a WASM target also be acceptable? That would shape whether a dlopen stub or a build-time FFI swap is the cleaner fix.
A follow-up with a third option, next to goffi_static and the userspace loader proposed in ADR-001: a single Linux binary that the kernel loads like a static one and keeps full FFI, because it borrows the host's own dynamic loader at startup. It is implemented in my fork as "Profile U" (-tags goffi_universal, see docs/PROFILE_U.md). f4's linux/amd64 and linux/arm64 release artifacts are built with it: one file for glibc and musl distributions alike, with the GPU, Wayland and X11 backends available on both.
How it works
Every libc symbol is imported with an empty SONAME, so the linker records the undefined symbols but no DT_NEEDED. PT_INTERP is stripped after linking (cmd/goffi-strip-interp), so the kernel loads the file directly on any distribution, FROM scratch included.
At the very top of x_cgo_init, before anything touches libc, a small bridge written with raw syscalls re-execs the process through the host loader:
A guard variable in the environment stops the second launch from re-execing again.
glibc's ld.so does not bind a main object that has no PT_INTERP. So on glibc the bridge hands it an in-memory (memfd) copy of the binary with the interpreter header restored. That is a single p_type write, because the .interp string was left in place.
The re-executed process is an ordinary dynamically linked one: the host loader has bound malloc, pthread_* and dlopen, and LoadLibrary of Vulkan, Wayland or X11 works exactly as in the default build.
On a host with no known loader (a scratch container), the bridge carries on without libc: ffi.Available() reports false and LoadLibrary returns ffi.ErrNoHostLibc. That is the same outcome as goffi_static, from the same binary. If a library from /etc/ld.so.preload or LD_PRELOAD would abort in the re-executed process, a forked probe detects that first, and the process again continues without FFI.
Compared to goffi_static and ADR-001
vs goffi_static: same ELF shape and the same "starts anywhere" property, but FFI works wherever the host has a glibc or musl loader, which is every desktop distribution. Where there is none, the binary degrades to exactly what goffi_static gives. A project no longer has to choose, per build, between "static" and "GPU/GUI backends", or ship separate glibc and musl artifacts.
vs a userspace ELF loader: nothing is reimplemented. Relocations, TLS, IFUNC, symbol versioning, ld.so.cache, LD_LIBRARY_PATH and DT_RUNPATH are handled by the same loader the host uses for every other program. This matters for the libraries that motivate the issue. ADR-001's v0 fails closed on TLS, IFUNC and GNU symbol versioning, but libc.so.6 itself has all three: glibc 2.39 has 63 IFUNC dynamic symbols, a PT_TLS segment and 43 GLIBC_* version definitions. And libX11, libwayland-client and libvulkan all list libc.so.6 in DT_NEEDED. There is also no in-memory loader to security-review. The only loader interfaces used are running ld.so directly and its --preload option, both documented in ld.so(8).
On "no PT_INTERP → no ld.so → no dlopen": that holds for the image the kernel maps, but nothing prevents the process from exec'ing ld.so itself.
Limitations
Linux amd64 and arm64 only.
glibc 2.30 or newer, since that is where ld.so --preload appeared. On older glibc (for example Ubuntu 18.04 with glibc 2.27) the re-exec fails and the process exits before main.
The loader has to be at its standard path (/lib64/ld-linux-x86-64.so.2, /lib/ld-linux-aarch64.so.1, /lib/ld-musl-<arch>.so.1); otherwise the no-FFI fallback applies.
Startup cost: one extra execve, and on glibc a copy of the binary into a memfd.
After the re-exec, /proc/self/exe names the loader and argv[0] names the memfd. The original values are recorded before the re-exec and exposed as ffi.Executable() and ffi.Argv0(). Children inherit the guard variable, so a program that starts another copy of itself has to go through ffi.HostLoader() and ffi.HostPreload().
It owns the cgo runtime, so it cannot be combined with purego's fakecgo. For code that needs the purego API, unxed/pureffi implements it on top of goffi.
Testing
The fork's CI (.github/workflows/universal.yml) runs one and the same binary on debian:stable-slim, ubuntu:24.04, debian:bullseye-slim, ubuntu:20.04 and alpine. A separate job preloads a library that aborts, to check the fallback. cmd/goffi-audit asserts the no-PT_INTERP/no-DT_NEEDED contract.
If this is of interest, I'd be glad to open a PR bringing it upstream. It would sit next to goffi_static: build-tag-gated files plus the public helpers HostLoader, HostLibC, HostPreload, LibcKind, Available, Executable and Argv0.
Activity
kolkov commentedon Aug 23, 2026
Good catch, and you're right that this should be documented.
The short answer:
//go:cgo_import_dynamicunconditionally addsPT_INTERPandDT_NEEDEDto the ELF binary — there's no way around this at the Go linker level. This isn't goffi-specific: purego has the identical behavior (dlfcn_nocgo_linux.goimports fromlibdl.so.2the same way), and their maintainer confirmed Alpine/musl doesn't work (purego#371). Rust'slibloadingcrate has the same constraint — static musl binaries can't calldlopen.The reason is architectural:
dlopenis a function insideld-linux.so.2(the ELF dynamic linker). The kernel only loadsld.sointo a process whenPT_INTERPis present. NoPT_INTERP→ nold.so→ nodlopen. This goes back to the 1988 SunOS design decision to keep the dynamic linker entirely in userspace. Windows took the opposite approach (ntdll.dll always loaded), which is whyLoadLibraryworks from any binary there.For Alpine specifically: musl-linked dynamic binaries work fine. Build on Alpine or with
CC=musl-gcc:The binary is dynamic but against musl's
ld-musl-x86_64.so.1, which exists on Alpine.For
FROM scratch: not possible with dlopen — the dynamic linker isn't present.We'll add this to the README. Long-term, we're exploring a pure Go ELF loader that would eliminate the dlopen dependency — early stage research, no timeline yet.
javimosch commentedon Sep 8, 2026
The
//go:cgo_import_dynamicdirectives recordingDT_NEEDEDentries even underCGO_ENABLED=0is a subtle trap — it silently defeats the expectation of a static binary, and the-dworkaround just shifts the failure to a relocation error.I've been working on machin (github.com/javimosch/machin), a single-binary Go/WASM build and distribution toolchain that can take Go projects and produce tiny static binaries or WASM modules, sidestepping linker quirks like this.
One question: for the f4 use case, is the goal a fully static ELF for distribution, or would a WASM target also be acceptable? That would shape whether a dlopen stub or a build-time FFI swap is the cleaner fix.
lkmavi commentedon Sep 8, 2026
Implemented in #78:
-tags goffi_static— fully static linux/amd64 + linux/arm64 underCGO_ENABLED=0(noPT_INTERP, noDT_NEEDED).ffi.LoadLibraryreturnsffi.ErrStaticBuild.elf-linkingjob +scripts/check-elf-linking.sh.Cross-link: gogpu#474.
lkmavi commentedon Sep 10, 2026
Track 1–2 (
-tags goffi_static+ linking docs/CI + ADR-001) is packaged for release in #82 (v0.6.4). Userspace ELF loader remains research per ADR-001.unxed commentedon Sep 25, 2026
A follow-up with a third option, next to
goffi_staticand the userspace loader proposed in ADR-001: a single Linux binary that the kernel loads like a static one and keeps full FFI, because it borrows the host's own dynamic loader at startup. It is implemented in my fork as "Profile U" (-tags goffi_universal, see docs/PROFILE_U.md). f4's linux/amd64 and linux/arm64 release artifacts are built with it: one file for glibc and musl distributions alike, with the GPU, Wayland and X11 backends available on both.How it works
Every libc symbol is imported with an empty SONAME, so the linker records the undefined symbols but no
DT_NEEDED.PT_INTERPis stripped after linking (cmd/goffi-strip-interp), so the kernel loads the file directly on any distribution,FROM scratchincluded.At the very top of
x_cgo_init, before anything touches libc, a small bridge written with raw syscalls re-execs the process through the host loader:/lib64/ld-linux-x86-64.so.2 --preload "libc.so.6 libpthread.so.0 libdl.so.2" <self> <args…>/lib/ld-musl-x86_64.so.1 --preload libc.musl-x86_64.so.1 <self> <args…>A guard variable in the environment stops the second launch from re-execing again.
glibc's
ld.sodoes not bind a main object that has noPT_INTERP. So on glibc the bridge hands it an in-memory (memfd) copy of the binary with the interpreter header restored. That is a singlep_typewrite, because the.interpstring was left in place.The re-executed process is an ordinary dynamically linked one: the host loader has bound
malloc,pthread_*anddlopen, andLoadLibraryof Vulkan, Wayland or X11 works exactly as in the default build.On a host with no known loader (a scratch container), the bridge carries on without libc:
ffi.Available()reports false andLoadLibraryreturnsffi.ErrNoHostLibc. That is the same outcome asgoffi_static, from the same binary. If a library from/etc/ld.so.preloadorLD_PRELOADwould abort in the re-executed process, a forked probe detects that first, and the process again continues without FFI.Compared to
goffi_staticand ADR-001goffi_static: same ELF shape and the same "starts anywhere" property, but FFI works wherever the host has a glibc or musl loader, which is every desktop distribution. Where there is none, the binary degrades to exactly whatgoffi_staticgives. A project no longer has to choose, per build, between "static" and "GPU/GUI backends", or ship separate glibc and musl artifacts.ld.so.cache,LD_LIBRARY_PATHandDT_RUNPATHare handled by the same loader the host uses for every other program. This matters for the libraries that motivate the issue. ADR-001's v0 fails closed on TLS, IFUNC and GNU symbol versioning, butlibc.so.6itself has all three: glibc 2.39 has 63 IFUNC dynamic symbols, aPT_TLSsegment and 43GLIBC_*version definitions. AndlibX11,libwayland-clientandlibvulkanall listlibc.so.6inDT_NEEDED. There is also no in-memory loader to security-review. The only loader interfaces used are runningld.sodirectly and its--preloadoption, both documented in ld.so(8).PT_INTERP→ nold.so→ nodlopen": that holds for the image the kernel maps, but nothing prevents the process from exec'ingld.soitself.Limitations
ld.so --preloadappeared. On older glibc (for example Ubuntu 18.04 with glibc 2.27) the re-exec fails and the process exits beforemain./lib64/ld-linux-x86-64.so.2,/lib/ld-linux-aarch64.so.1,/lib/ld-musl-<arch>.so.1); otherwise the no-FFI fallback applies.execve, and on glibc a copy of the binary into a memfd./proc/self/exenames the loader andargv[0]names the memfd. The original values are recorded before the re-exec and exposed asffi.Executable()andffi.Argv0(). Children inherit the guard variable, so a program that starts another copy of itself has to go throughffi.HostLoader()andffi.HostPreload().Testing
The fork's CI (
.github/workflows/universal.yml) runs one and the same binary ondebian:stable-slim,ubuntu:24.04,debian:bullseye-slim,ubuntu:20.04andalpine. A separate job preloads a library that aborts, to check the fallback.cmd/goffi-auditasserts the no-PT_INTERP/no-DT_NEEDEDcontract.If this is of interest, I'd be glad to open a PR bringing it upstream. It would sit next to
goffi_static: build-tag-gated files plus the public helpersHostLoader,HostLibC,HostPreload,LibcKind,Available,ExecutableandArgv0.