Saturday, September 5, 2026
HomeGolangStatic C linking with out Cgo: Automating Plan 9 stubs and libc-free...

Static C linking with out Cgo: Automating Plan 9 stubs and libc-free .syso objects from Go-syntax glue – Technical Dialogue


Cgo offers entry to native C code, however at a well known value: it breaks easy cross-compilation, complicates single-binary distribution, and incurs runtime invocation overhead from switching between goroutine stacks and OS threads.

For dynamic linking, instruments like PureGo solved this on Unix, and commonplace runtime lazy DLLs deal with Home windows. Nevertheless, for static linking with out Cgo (CGO_ENABLED=0), the accessible path has at all times been painful: writing guide Plan 9 meeting stubs and stitching collectively uncooked object information.

I constructed a toolchain known as Hike (hikec) to automate this pipeline end-to-end. It lets you write low-level C-ABI appropriate routines and glue logic utilizing Go syntax, mechanically emitting Plan 9 meeting stubs and clear .syso information that go construct merges immediately into the ultimate binary.

The Go linker (cmd/hyperlink) has a built-in function: any name_GOOS_GOARCH.syso file positioned inside a bundle listing is statically linked into the ultimate binary, even when Cgo is totally disabled.

Making this work requires fixing two issues:

1. ABI Mismatch: Go’s inside ABI differs from commonplace C ABIs (e.g., Home windows x64 ABI or System V AMD64 ABI). A Plan 9 meeting stub should bridge register assignments, allocate shadow stack area, and go return values again to the Go body pointer.

2. Zero Libc / CRT Dependencies: As a result of the pure Go linker doesn’t hyperlink towards the C runtime, the .syso file can’t comprise any unresolved exterior symbols (resembling malloc, printf, or platform-specific probes like ___chkstk_ms).

As a substitute of writing C glue and hand-crafting Plan 9 meeting, you write .go.hike information utilizing commonplace Go syntax with a cfunc key phrase:

“`go

bundle fizzlib

// Customary C-ABI exported operate

cfunc GetFizzFileSize(fn_ptr cstring, fn_len int) int {

return 15

}

// passthrough: Emits NOSPLIT in meeting, executing immediately

// 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"

}

“`

Operating `hikec go ./fizzlib` executes the next pipeline:

1. LLVM IR Technology: Compiles the Hike AST into LLVM IR.

2. Useless-Code Elimination (-O2): Unused runtime symbols (e.g., libc allocations) are utterly eradicated by the optimizer.

3. Probe Suppression: Clang compiles the IR with -mno-stack-arg-probe to suppress Home windows CRT stack-probe helper symbols.

4. Meeting Stub Technology: Mechanically generates stub_windows_amd64.s dealing with the register setup (mapping Go arguments from FP to CX, DX, R8, R9 and reserving 32 bytes of shadow area).

5. Output: Produces fizzlib_windows_amd64.syso and stub_windows_amd64.s immediately within the bundle listing.

The meeting bridge generated by the compiler seems like this:

“`plan9

#embrace “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 work together with the bundle utilizing commonplace idiomatic Go code (fizzlib.go):

“`go

bundle fizzlib

import “unsafe”

//go:noescape

func _hike_GetFizzFileSize(ptr unsafe.Pointer, size 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))

}

“`

Operating `go construct` or `go run` produces a single static binary with no need GCC, MinGW, or CGO_ENABLED=1.

Constraints & Scope:

* Leaf Capabilities & Stack Budgets: Capabilities marked with passthrough execute immediately on the calling goroutine’s stack (NOSPLIT). Giant stack allocations should be averted or allotted on the Go aspect.

* No Libc Calls: The static native code can’t name arbitrary commonplace library C features like malloc or printf until these implementations are bundled into the .syso with out exterior image references.

* Platform Help: At the moment examined and validated on Home windows x86_64. Extending to Linux/macOS System V AMD64 ABI requires adapting the register mapping sequence within the stub generator (DI, SI, DX, CX, R8, R9).

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments