Go as a Systems Language
Go as a Systems Language
Go is often introduced as a cloud and API language. That understates the design. Docker, containerd, Kubernetes, etcd, Prometheus, and Terraform are userspace systems software: they create processes, talk to the kernel, ship as a single file, and have to run on machines the author never configured.
This chapter is the why behind that property. Later chapters in this part teach processes, files, pipelines, and supervisors. Here you learn how a Go binary reaches the kernel without libc, why that matters for portability, and when C is still the right tool.
Structure inspired by public systems writeups (notably Using Go for Systems Programming on iximiuz Labs). Prose, diagrams, and examples here are original to this book.
Why this belongs in a Go book
A C programmer learning process creation, mmap, or file descriptors usually goes through libc: the C standard library. POSIX is specified in C. The Linux kernel is written in C. libc is the conventional bridge from userspace to the kernel.
Go takes a different route on Linux: the runtime and standard library issue system calls themselves. The resulting binary does not need libc.so on the target. That is why a CGO_ENABLED=0 Linux binary can run on Debian, Alpine, NixOS, or a scratch container as long as the kernel ABI matches.
You still cannot write kernel modules or firmware in Go. “Systems language” here means portable userspace that speaks the OS, not “replaces C everywhere.”
C (typical Linux build)
-----------------------
your code --> libc (printf, malloc, fopen)
|
v
kernel (write, brk/mmap, open)
Go on Linux (CGO_ENABLED=0)
---------------------------
your code --> Go stdlib + runtime
|
v
kernel (same syscalls, no libc in the middle)
How a C program depends on libc
printf is not a system call. It is a libc function. libc formats the string, then eventually invokes the kernel’s write. malloc eventually becomes brk or mmap. fopen becomes open. Your C source talks to libc; libc talks to the kernel.
A default gcc hello.c -o hello on Linux produces a dynamically linked ELF. The binary does not contain libc. At start-up the kernel loads a dynamic linker (the ELF interpreter) which then maps shared libraries:
| Distro family | Dynamic linker you typically see |
|---|---|
| Debian, Ubuntu, Fedora, RHEL (glibc) | /lib64/ld-linux-x86-64.so.2 |
| Alpine (musl) | /lib/ld-musl-x86_64.so.1 |
Inspect it:
file ./hello
# ELF 64-bit ... dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2 ...
ldd ./hello
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6ldd lists shared-library dependencies. If the interpreter path or a required .so is missing, the program does not run.
The “not found” surprise
Copy a glibc-linked binary onto Alpine and run it. The shell often prints not found even though the file exists and is executable. The kernel is not looking for your file — it is looking for the interpreter named in the ELF header (/lib64/ld-linux-x86-64.so.2). Alpine does not ship that file.
glibc and musl implement the same C API on paper and are not binary-compatible. Symbol versions differ. Dynamic linker paths differ. A dynamically linked C tool built on Ubuntu is not a portable Linux tool.
You can statically link C (gcc -static). That removes the runtime .so search, but the binary still embeds one libc’s implementation and assumptions. It is larger, licensing of glibc-static is awkward, and you still do not get Go’s own scheduler, allocator, and syscall wrappers.
How a Go program reaches the kernel
Compile a tiny program with the Go toolchain:
package main
import "fmt"
func main() {
fmt.Println("hello from a self-contained binary")
}CGO_ENABLED=0 go build -o hello-go .
file hello-go
# On Linux: ELF 64-bit ... statically linked ...Statically linked means the kernel can map this one file and run it. There is no ELF interpreter for libc, no libc.so to locate.
ldd hello-go
# Linux glibc ldd: "not a dynamic executable" or "statically linked"
# Alpine musl ldd: "Not a valid dynamic program"Both messages mean the same thing: there is no dynamic section to inspect.
Copy the binary to /tmp on a machine that does not have the Go toolchain installed. It still runs. The host libc does not matter. The kernel syscall interface does.
What is actually inside
Every Go program includes the Go runtime:
- goroutine scheduler (Gs on Ms, with Ps)
- memory allocator and garbage collector
- signal handling and stack growth
- the assembly stubs that place syscall arguments in the right registers and execute
syscall(Linux)
On Linux those stubs live in the standard library / runtime (Syscall, RawSyscall, and architecture-specific asm_linux_*.s files). You do not call them for ordinary I/O — os, fmt, net, and io do.
strace makes the difference visible. A C printf hello shows a short list (write, exit_group, a bit of libc setup). A Go hello shows more: mmap, mprotect, clone, futex, rt_sigaction — the runtime bringing up its own world — then write for the line of text. Those calls originate in the Go binary, not in libc.
C hello Go hello (Linux, no cgo)
------- -----------------------
loader + libc init runtime init (mmap, clone, futex, ...)
write("hello\n") write("hello\n")
exit_group exit_group
Linux vs other kernels
This “no libc” story is Linux-specific:
GOOS |
How Go talks to the OS |
|---|---|
linux |
Direct syscalls; CGO_ENABLED=0 binaries are fully static |
darwin |
Through libSystem — Apple does not treat the raw syscall table as a stable ABI |
windows |
Windows APIs / ntdll, not a Unix libc |
freebsd / others |
OS-specific; do not assume Linux static behaviour |
The portability win that made Go famous in containers — one file, Alpine or Debian, scratch image — is the Linux + CGO_ENABLED=0 combination.
CGO puts libc back in the picture
cgo is how Go calls C. When it is enabled, the linker may pull in libc and you are back to dynamic dependencies.
On a machine with a C compiler, CGO_ENABLED defaults to 1. A package that looks like pure Go can still pick up cgo through net (system DNS resolver) or os/user (NSS lookups) unless you disable it.
# Guarantee a pure-Go, fully static Linux binary:
CGO_ENABLED=0 go build -o hello-go .
# Confirm what the toolchain actually used:
go env CGO_ENABLED
go version -m hello-go | grep CGOWith CGO_ENABLED=0, networking and user lookup use pure-Go implementations (Go’s DNS resolver, Go’s /etc/passwd parsing). That is usually what you want for containers and cross-compiled CLIs. Use cgo when you must call a C library that has no acceptable Go equivalent — and then accept a C toolchain, harder cross-compiles, and a libc on the target. See cgo.
Cross-compilation is a systems feature
Because the toolchain ships the runtime and standard library for many GOOS/GOARCH pairs, you can build a Linux AMD64 binary on a Mac or a Windows box:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o hello-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o hello-linux-arm64 .The compiler emits Plan 9 pseudo-assembly (an architecture-neutral IR with SB for static base, SP for the stack) and the Go linker writes the final ELF/Mach-O/PE file. You do not need a cross-gcc unless cgo is on.
Inspect what the compiler produced:
go build -gcflags=-S . # Plan 9-ish listing on stderr
go tool objdump -s main.main hello-linux-amd64go tool objdump understands every architecture the toolchain can emit. You can disassemble an ARM64 binary on an x86-64 laptop; the text uses Go’s register names (R0…, R28 as the current g on ARM64), not necessarily the vendor ISA manual’s names.
Deeper ABI and NOSPLIT notes live in ABI, assembly, and go:nosplit.
Trade-offs: Go vs C for systems work
Why teams pick Go
| Strength | What it buys you |
|---|---|
| Static Linux binaries | One file; no glibc/musl lottery; scratch / distroless images |
| Memory safety by default | Bounds checks, no pointer arithmetic; fewer class-of-bug CVEs |
| Goroutines | Concurrent servers and CLIs without hand-rolled pthreads |
| Fast builds + first-class cross-compile | CI matrix without a C cross-toolchain |
| Batteries in the stdlib | HTTP, TLS, JSON, crypto — no hunt for a hash map |
That combination is why so much cloud infrastructure is Go. The programs need to run in many environments, talk to the kernel, and stay easy to build.
What you pay
| Cost | Consequence |
|---|---|
| Runtime + GC in every binary | Larger files; CPU/memory for the scheduler and collector |
| GC is not hard-real-time | Fine for almost all servers and CLIs; wrong for hard µs deadlines |
| Less control of layout | C still wins for packed DMA structures and bare metal |
| Not for the kernel | Linux modules and firmware stay C (or Rust, in some trees) |
The Go GC has improved a lot (including the Green Tea collector becoming the default in Go 1.26). “GC pause” is rarely the reason to reject Go for a network service. It is a reason to reject Go for a hard real-time controller.
Choose C when
- You are writing kernel code, a driver, or firmware
- Every byte and cycle is budgeted (tiny MCUs, some embedded)
- You need deterministic allocation with no collector
- You must control exact memory layout and registers
Choose Go when
- You are building network services, CLIs, or DevOps / cloud control planes
- You want safe concurrency without managing OS threads by hand
- You need one Linux binary that runs across distros and in
scratch - You would rather not manage memory by hand
Go does not replace C. It occupies the large space of userspace systems software where C’s portability tax is higher than Go’s runtime tax.
Literacy checklist
- Explain why
printfis not a syscall - Name glibc vs musl and why a dynamically linked Ubuntu binary fails on Alpine
- Read
fileandlddwell enough to spot a libc dependency - Explain why
./binary: not foundcan mean “missing dynamic linker” - Build with
CGO_ENABLED=0and know what it changes (DNS,os/user, linkage) - Cross-compile with
GOOS/GOARCHwithout a C cross-compiler - Know that the no-libc story is Linux-specific (Darwin uses libSystem)
- List one reason to pick C anyway (kernel, hard real-time, layout)
Exercises
- Link story — On Linux, compile a 5-line C hello and a 5-line Go hello. Run
fileandlddon both. Write one sentence about each output. - Force static Go — Build the same Go program twice, once with
CGO_ENABLED=0and once with the default. Comparelddandls -l. If they match, checkgo env CGO_ENABLEDand whether any imported package uses cgo. - Cross-compile — From whatever OS you use, produce
GOOS=linux GOARCH=amd64 CGO_ENABLED=0andGOOS=linux GOARCH=arm64 CGO_ENABLED=0binaries. Disassemblemain.mainof both withgo tool objdump -s main.main. - strace (Linux) —
strace -cboth hellos. Which extra syscalls does the Go runtime need just to start? - The Alpine thought experiment — Without a spare Alpine box: given an ELF interpreter of
/lib64/ld-linux-x86-64.so.2, predict the exact failure mode on a musl-only rootfs.
Runnable example
This program reports what the binary believes about its build, then writes one line. After go build, inspect the artifact with file / ldd (Linux) or otool -L (macOS).
mkdir -p /tmp/go-as-sys && cd /tmp/go-as-sys
go mod init example.com/as-sysSave as main.go:
package main
import (
"fmt"
"os"
"runtime"
"runtime/debug"
)
func cgoEnabled() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "unknown"
}
for _, s := range info.Settings {
if s.Key == "CGO_ENABLED" {
return s.Value
}
}
return "unknown"
}
func main() {
fmt.Fprintf(os.Stdout, "goos=%s goarch=%s cgo=%s\n",
runtime.GOOS, runtime.GOARCH, cgoEnabled())
fmt.Fprintln(os.Stdout, "this write is a kernel syscall on Linux — no libc required")
}CGO_ENABLED=0 go build -o as-sys .
./as-sys
# Linux:
file ./as-sys
ldd ./as-sys || true
# Cross-compile a Linux static binary from any host:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o as-sys-linux-amd64 .
file ./as-sys-linux-amd64Expected output (host fields vary):
goos=linux goarch=amd64 cgo=0
this write is a kernel syscall on Linux — no libc required
What to notice
runtime.GOOSis the target you compiled for, not necessarily the machine you typed on.CGO_ENABLED=0is a build-time switch;ReadBuildInforecords what the linker used.- On Linux,
fileshould say statically linked. On macOS the same source still linkslibSystem— that is Darwin, not a failed build.
Try next
go tool objdump -s main.main ./as-sysand find theCALLtofmt.Fprintf.- Continue with Processes, signals, and supervisors: the binary you just built is the process those chapters supervise.
Next Chapter
Systems Programming Overview — map of processes, files, pipelines, and the ops toolkit. Then start with processes and signals.