Go as a Systems Language

Updated

September 8, 2026

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

ldd 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 CGO

With 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-amd64

go 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 printf is not a syscall
  • Name glibc vs musl and why a dynamically linked Ubuntu binary fails on Alpine
  • Read file and ldd well enough to spot a libc dependency
  • Explain why ./binary: not found can mean “missing dynamic linker”
  • Build with CGO_ENABLED=0 and know what it changes (DNS, os/user, linkage)
  • Cross-compile with GOOS / GOARCH without 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

  1. Link story — On Linux, compile a 5-line C hello and a 5-line Go hello. Run file and ldd on both. Write one sentence about each output.
  2. Force static Go — Build the same Go program twice, once with CGO_ENABLED=0 and once with the default. Compare ldd and ls -l. If they match, check go env CGO_ENABLED and whether any imported package uses cgo.
  3. Cross-compile — From whatever OS you use, produce GOOS=linux GOARCH=amd64 CGO_ENABLED=0 and GOOS=linux GOARCH=arm64 CGO_ENABLED=0 binaries. Disassemble main.main of both with go tool objdump -s main.main.
  4. strace (Linux)strace -c both hellos. Which extra syscalls does the Go runtime need just to start?
  5. 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-sys

Save 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-amd64

Expected 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.GOOS is the target you compiled for, not necessarily the machine you typed on.
  • CGO_ENABLED=0 is a build-time switch; ReadBuildInfo records what the linker used.
  • On Linux, file should say statically linked. On macOS the same source still links libSystem — that is Darwin, not a failed build.

Try next

  • go tool objdump -s main.main ./as-sys and find the CALL to fmt.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.