Static C linking without Cgo: Automating Plan 9 stubs and libc-free .syso objects from Go-syntax glue

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

Well, formatting is borked on this. You might want to edit it to fix the codefences and such.