Go version

go version go1.24.2 linux/amd64

Output of go env in your module/workspace:

(Some output here and below manually modified to hide internal configuration)

AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN=''
GOCACHE='$HOME/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='$HOME/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build353822596=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='$HOME/tmp/go-cross/go.mod'
GOMODCACHE='$HOME/go/pkg/mod'
GONOPROXY=''
GONOSUMDB='$INTERNAL'
GOOS='linux'
GOPATH='$HOME/go'
GOPRIVATE=''
GOPROXY='$INTERNAL,direct'
GOROOT='$HOME/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.2.linux-amd64'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='$HOME/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='$HOME/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.2.linux-amd64/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.24.2'
GOWORK=''
PKG_CONFIG='pkg-config'

What did you do?

Attempted to cross-compile a cgo binary from amd64 to arm64 using a private LLVM/Clang-based toolchain and sysroot. I was able to reproduce this from arm64 crossing to amd64 as well.

Minimal example (which doesn't run in the playground due to needing cgo): https://go.dev/play/p/a0qGj4Vakf-

Built with:

CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=/path/to/private/clang CGO_CFLAGS="--sysroot=/path/to/private/sysroot-arm --target=aarch64-unknown-linux-gnu" \
    go build \
        -ldflags="-v -extld C/path/to/private/clang -extldflags '-fuse-ld=lld --sysroot=/path/to/private/sysroot-arm --target=aarch64-unknown-linux-gnu --verbose'" \
        -gcflags=-v

The output binary can be run on an ARM host, or via qemu with something like:

qemu-aarch64 -L /path/to/private/sysroot-arm ./go-cross

What did you see happen?

The go tool link output from the above go build command contained a linker invocation like this:

(The "host link" outputs below are particularly long, and is more readable in an editor)

host link: "/path/to/private/clang" "-o" "/tmp/go-build481106842/b001/exe/a.out" "-rdynamic" "/tmp/go-link-168810886/go.o" "/tmp/go-link-168810886/000000.o" "/tmp/go-link-168810886/000001.o" "/tmp/go-link-168810886/000002.o" "/tmp/go-link-168810886/000003.o" "/tmp/go-link-168810886/000004.o" "/tmp/go-link-168810886/000005.o" "/tmp/go-link-168810886/000006.o" "/tmp/go-link-168810886/000007.o" "/tmp/go-link-168810886/000008.o" "/tmp/go-link-168810886/000009.o" "/tmp/go-link-168810886/000010.o" "/tmp/go-link-168810886/000011.o" "/tmp/go-link-168810886/000012.o" "/tmp/go-link-168810886/000013.o" "/tmp/go-link-168810886/000014.o" "/tmp/go-link-168810886/000015.o" "-O2" "-g" "-O2" "-g" "-lpthread" "-fuse-ld=lld" "--sysroot=/path/to/private/sysroot-arm" "--target=aarch64-unknown-linux-gnu" "--verbose"

If I use the same compiler targeting the host architecture, using a sysroot compiled for it (in this case amd64/x86_64), the output is different:

CGO_ENABLED=1 GOOS=linux GOARCH=amd64 CC=/path/to/private/clang CGO_CFLAGS="--sysroot=/path/to/private/sysroot-amd64 --target=x86_64-unknown-linux-gnu" \
    go build \
        -ldflags="-v -extld C/path/to/private/clang -extldflags '-fuse-ld=lld --sysroot=/path/to/private/sysroot-amd64 --target=x86_64-unknown-linux-gnu --verbose'" \
        -gcflags=-v

...

host link: "$HOME/tmp/llvm-17.0.6-dist-x86_64/bin/clang" "-m64" "-Wl,--build-id=0xe3c881e6feaf37cd5c2a7e4e643c549ff859ee17" "-o" "/tmp/go-build3873136084/b001/exe/a.out" "-Wl,--export-dynamic-symbol=_cgo_panic" "-Wl,--export-dynamic-symbol=_cgo_topofstack" "-Wl,--export-dynamic-symbol=crosscall2" "-Qunused-arguments" "-Wl,--compress-debug-sections=zlib" "/tmp/go-link-1498717480/go.o" "/tmp/go-link-1498717480/000000.o" "/tmp/go-link-1498717480/000001.o" "/tmp/go-link-1498717480/000002.o" "/tmp/go-link-1498717480/000003.o" "/tmp/go-link-1498717480/000004.o" "/tmp/go-link-1498717480/000005.o" "/tmp/go-link-1498717480/000006.o" "/tmp/go-link-1498717480/000007.o" "/tmp/go-link-1498717480/000008.o" "/tmp/go-link-1498717480/000009.o" "/tmp/go-link-1498717480/000010.o" "/tmp/go-link-1498717480/000011.o" "/tmp/go-link-1498717480/000012.o" "/tmp/go-link-1498717480/000013.o" "/tmp/go-link-1498717480/000014.o" "/tmp/go-link-1498717480/000015.o" "-O2" "-g" "-O2" "-g" "-lpthread" "-no-pie" "-fuse-ld=lld" "--sysroot=/path/to/private/sysroot-amd64" "--target=x86_64-unknown-linux-gnu" "--verbose"

Both commands succeed, and produce a binary.

These flag differences are numerous and consequential: running the cross-compiled binary (in this particular case, using qemu) crashes at startup like this:

> qemu-aarch64 -L /path/to/private/sysroot-arm ./go-cross
runtime: pcHeader: magic= 0xfffffff1 pad1= 0 pad2= 0 minLC= 4 ptrSize= 8 pcHeader.textStart= 0xd9d20 text= 0x7f051fca9d20 pluginpath=
fatal error: invalid function symbol table
runtime: panic before malloc heap initialized

runtime stack:
runtime.throw({0x7f051fbff423?, 0x0?})
    $HOME/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.2.linux-amd64/src/runtime/panic.go:1101 +0x38 fp=0x4000008000b0 sp=0x400000800080 pc=0x7f051fd11208
runtime.moduledataverify1(0x7f051fd8d5e0?)
    $HOME/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.2.linux-amd64/src/runtime/symtab.go:623 +0x644 fp=0x4000008001d0 sp=0x4000008000b0 pc=0x7f051fd13704
runtime.moduledataverify(...)
    $HOME/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.2.linux-amd64/src/runtime/symtab.go:599
runtime.schedinit()
    $HOME/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.2.linux-amd64/src/runtime/proc.go:834 +0x88 fp=0x400000800260 sp=0x4000008001d0 pc=0x7f051fce2fd8
runtime.rt0_go()
    $HOME/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.2.linux-amd64/src/runtime/asm_arm64.s:86 +0xa4 fp=0x400000800290 sp=0x400000800260 pc=0x7f051fd15154

The binary fails similarly on an actual ARM host as well. When I investigated this, it seems that the critical difference is the -no-pie flag in the second (amd64, same as the build host) output, as adding -buildmode pie to the cross-compiling go build prevents the binary from crashing like this.

What did you expect to see?

The flags in the two "host link" invocations above should be more similar (aside from arch-specific flags), and the cross-compiled program should not crash as mentioned above when run in an emulator or a system with that architecture.

Comment From: apsaltis-ddog

I think the underlying problem is in how go tool link is doing tests to determine what flags it can pass to the underlying linker. While exploring, I found trimLinkerArgv in the linker code. Notably, it contains, -target in a few places, but doesn't mention --target anywhere.

Clang supports both ways of specifying targets, but they are used differently: -target only expects the target in a second arg, and --target only expects it in the same arg (--target=$ARG).

From the looks of it, in this list: https://github.com/golang/go/blob/2cb9e7f68f90ea9119fd4172fc61630279d79d67/src/cmd/link/internal/ld/lib.go#L2186-L2195

-target should be --target, or --target should be added to the list. I'd be happy to create a PR that would do this -- this created non-crashing binaries for me.

Comment From: gabyhelp

Related Issues

Related Discussions

(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)

Comment From: mknyszek

@apsaltis-ddog Your fix seems reasonable. Thanks for investigating. Feel free to send a patch!

Comment From: gopherbot

Change https://go.dev/cl/697340 mentions this issue: cmd/link: ensure that the "--target" clang flag is considered when testing for C linker capabilities