Bypassing Cgo for static linking: Lessons learned from using .syso and generating Plan 9 stubs

252 views
Skip to first unread message

Kanryu KATO

unread,
Sep 4, 2026, 5:14:53 PMSep 4
to golang-nuts
Cgo provides access to native C code, but at a well-known cost: it breaks straightforward cross-compilation, complicates single-binary distribution, and incurs runtime invocation overhead from switching between goroutine stacks and OS threads.

For dynamic linking, tools like PureGo solved this on Unix, and standard runtime lazy DLLs handle Windows. However, for static linking without Cgo (CGO_ENABLED=0), the available path has always been painful: writing manual Plan 9 assembly stubs and stitching together raw object files.

I built a toolchain called Hike (hikec) to automate this pipeline end-to-end. It allows you to write low-level C-ABI compatible routines and glue logic using Go syntax, automatically emitting Plan 9 assembly stubs and clean .syso files that go build merges directly into the final binary.

The Go linker (cmd/link) has a built-in feature: any <name>_<GOOS>_<GOARCH>.syso file placed inside a package directory is statically linked into the final binary, even when Cgo is completely disabled.

Making this work requires solving two problems:

1. ABI Mismatch: Go's internal ABI differs from standard C ABIs (e.g., Windows x64 ABI or System V AMD64 ABI). A Plan 9 assembly stub must bridge register assignments, allocate shadow stack space, and pass return values back to the Go frame pointer.
2. Zero Libc / CRT Dependencies: Because the pure Go linker does not link against the C runtime, the .syso file cannot contain any unresolved external symbols (such as malloc, printf, or platform-specific probes like ___chkstk_ms).

Instead of writing C glue and hand-crafting Plan 9 assembly, you write .go.hike files using standard Go syntax with a cfunc keyword:

```go
package fizzlib

// Standard C-ABI exported function
cfunc GetFizzFileSize(fn_ptr cstring, fn_len int) int {
    return 15
}

// passthrough: Emits NOSPLIT in assembly, executing directly
// on the goroutine stack with zero context-switching overhead
passthrough cfunc GetMetaData(fn_ptr cstring, fn_len int, outLen *int) cstring {
    *outLen = 4
    return "fizz"
}

```

Running `hikec go ./fizzlib` executes the following pipeline:

1. LLVM IR Generation: Compiles the Hike AST into LLVM IR.
2. Dead-Code Elimination (-O2): Unused runtime symbols (e.g., libc allocations) are completely eliminated by the optimizer.
3. Probe Suppression: Clang compiles the IR with -mno-stack-arg-probe to suppress Windows CRT stack-probe helper symbols.
4. Assembly Stub Generation: Automatically generates stub_windows_amd64.s handling the register setup (mapping Go arguments from FP to CX, DX, R8, R9 and reserving 32 bytes of shadow space).
5. Output: Produces fizzlib_windows_amd64.syso and stub_windows_amd64.s directly in the package directory.

The assembly bridge generated by the compiler looks like this:

```plan9
#include "textflag.h"

// GetFizzFileSize
TEXT ·_hike_GetFizzFileSize(SB), 0, $32-24
    MOVQ ptr_arg+0(FP), CX
    MOVQ len_arg+8(FP), DX
    SUBQ $32, SP
    CALL c_GetFizzFileSize(SB)
    ADDQ $32, SP
    MOVQ AX, ret+16(FP)
    RET

// GetMetaData (passthrough: NOSPLIT)
TEXT ·_hike_GetMetaData(SB), NOSPLIT, $32-32
    MOVQ fn_ptr_arg+0(FP), CX
    MOVQ fn_len_arg+8(FP), DX
    MOVQ outLen_arg+16(FP), R8
    SUBQ $32, SP
    CALL c_GetMetaData(SB)
    ADDQ $32, SP
    MOVQ AX, ret+24(FP)
    RET

```

In Go, you interact with the package using standard idiomatic Go code (fizzlib.go):

```go
package fizzlib

import "unsafe"

//go:noescape
func _hike_GetFizzFileSize(ptr unsafe.Pointer, length int) int

//go:noescape
func _hike_GetMetaData(fn unsafe.Pointer, fnLen int, outLen *int) unsafe.Pointer

func GetFizzFileSize(filename string) int {
    p := unsafe.StringData(filename)
    return _hike_GetFizzFileSize(unsafe.Pointer(p), len(filename))
}

func GetMetaData(filename string) string {
    p := unsafe.StringData(filename)
    var outLen int
    resPtr := _hike_GetMetaData(unsafe.Pointer(p), len(filename), &outLen)
    if resPtr == nil || outLen == 0 {
        return ""
    }
    return string(unsafe.Slice((*byte)(resPtr), outLen))
}

```

Running `go build` or `go run` produces a single static binary without needing GCC, MinGW, or CGO_ENABLED=1.

Constraints & Scope:

* Leaf Functions & Stack Budgets: Functions marked with passthrough execute directly on the calling goroutine's stack (NOSPLIT). Large stack allocations must be avoided or allocated on the Go side.
* No Libc Calls: The static native code cannot call arbitrary standard library C functions like malloc or printf unless those implementations are bundled into the .syso without external symbol references.
* Platform Support: Currently tested and validated on Windows x86_64. Extending to Linux/macOS System V AMD64 ABI requires adapting the register mapping sequence in the stub generator (DI, SI, DX, CX, R8, R9).

Jason E. Aten

unread,
Sep 5, 2026, 11:16:28 AMSep 5
to golang-nuts
Interesting. I wonder how much real work is viable on a NOSPLIT call stack... you didn't post a link to your project, so I can't just look and see.

How do you avoid the dreaded "PC is not in moduledata" crashes when calling non-Go generated functions? 

Sebastien Binet

unread,
Sep 5, 2026, 11:55:41 AMSep 5
to Jason E. Aten, golang-nuts
Sep 5, 2026 11:17:14 Jason E. Aten <j.e....@gmail.com>:

> Interesting. I wonder how much real work is viable on a NOSPLIT call stack... you didn't post a link to your project, so I can't just look and see.
>

I believe it's over there:

https://github.com/kanryu/hike-lang

I'd be interested in seeing how the story unfolds with memory management.

(Always wondered what could have been for a Go with arenas/regions and a Z3 solver like what salt-lang claims to achieve. When I noticed Austin Clements had played with z3, I naively thought it was perhaps coming :P)

-s

https://salt-lang.dev/
> --
> You received this message because you are subscribed to the Google Groups "golang-nuts" group.
> To unsubscribe from this group and stop receiving emails from it, send an email to golang-nuts...@googlegroups.com.
> To view this discussion visit https://groups.google.com/d/msgid/golang-nuts/684835af-b9a8-402c-98c9-6f8cb2b8c8b1n%40googlegroups.com[https://groups.google.com/d/msgid/golang-nuts/684835af-b9a8-402c-98c9-6f8cb2b8c8b1n%40googlegroups.com?utm_medium=email&utm_source=footer].

signature.asc

Kanryu KATO

unread,
Sep 5, 2026, 8:29:30 PMSep 5
to golang-nuts

Hi Jason,

Thanks for the questions! First, here is the project repository: https://github.com/kanryu/hike-lang

Here is how Hike handles these low-level aspects:

  1. How "PC is not in moduledata" is avoided: Hike emits a pair of files for Go: the native .syso object file and the accompanying Plan 9 assembly file (stub.s). When you run go build, the Go toolchain automatically discovers both .syso and stub.s in the package directory and statically links them using Go's official, standard linking procedure. Because they are integrated through Go's own supported static linking mechanism, the symbols and code addresses are registered properly by the linker, so the "PC is not in moduledata" error simply does not occur.

  2. Work viable on a NOSPLIT stack: By default, the assembly layer in stub.s generated by Hike automatically inserts a bidirectional trampoline. It switches the execution context from Go's goroutine stack to the OS/system stack before calling the foreign function, and switches back upon return. This allows C/native libraries to run with a standard, full OS stack without overflowing Go's small stack.

Only when the programmer explicitly declares a function as passthrough cfunc, Hike skips this stack-switching trampoline. In this case, the call executes directly on the NOSPLIT stack with zero transition overhead, but the programmer takes full responsibility for ensuring the library logic safely fits within Go's ~2KB headroom.

Best regards, KATO


2026年9月5日土曜日 18:16:28 UTC+9 Jason E. Aten:

Kanryu KATO

unread,
Sep 5, 2026, 8:29:34 PMSep 5
to golang-nuts

Hi Sébastien,

Thanks for sharing the repository link and joining the thread!

To answer directly: at present, Hike implements no memory management runtime at all.

However, in the context of our primary use case—bridging existing C libraries into Go without Cgo—we believe this completely hands-off stance is actually the most sensible design. If Hike were to introduce its own memory management layer between Go's GC and C's native allocations, it would create conflicting lifecycles and pointer-tracking frictions, essentially reinventing the complexities that make Cgo difficult to work with. Acting as a strictly transparent, zero-runtime C-ABI pipeline allows Go and C to negotiate buffers and ownership directly without interference.

Looking ahead, as Hike evolves into an independent systems language, our plan is to provide three distinct, pluggable memory management models that developers can choose from on a per-project basis:

  1. Arena (Bump) Allocation: Region-based allocation ideal for thread-pool tasks and request pipelines, enabling rapid allocations and bulk reclamation upon completion.

  2. Reference Counting (ARC): Deterministic reclamation for resource-heavy system components without global stop-the-world pauses.

  3. Garbage Collection: Automatic collection for general-purpose programming where convenience is prioritized over manual lifetime tuning.

Salt's approach using Z3 for static verification is indeed fascinating. For now, keeping the compiler pipeline modular allows us to explore these strategies without sacrificing the lightweight nature of the Go-C interop boundary.

Best regards, KATO


2026年9月5日土曜日 18:55:41 UTC+9 Sebastien Binet:
Reply all
Reply to author
Forward
0 new messages