Writing a Go Program That Runs on the Linux Kernel


I’ve been curious about what actually happens between your Go code and the Linux kernel. We write fmt.Println("hello") and text shows up in the terminal, but there’s a lot going on underneath. So I decided to strip everything back and write a Go program that talks directly to the kernel using raw system calls — no fmt, no os, nothing from the standard library.

Here’s what I learned.

What does “runs on the Linux kernel” actually mean?

Every program running on Linux ultimately talks to the kernel through system calls (syscalls). When you write to the terminal, read a file, or allocate memory, your program is asking the kernel to do it on your behalf. The Go standard library wraps all of this up nicely, but you can skip those wrappers and invoke syscalls directly.

This matters because it reveals how thin the layer between your code and the kernel really is. Go’s syscall and golang.org/x/sys/unix packages give you the tools to make those calls yourself.

The simplest possible program

Let’s start with a program that writes “hello, kernel\n” to stdout and then exits — using nothing but syscalls.

package main

import "syscall"

func main() {
	msg := []byte("hello, kernel\n")

	// write(fd=1, buf, len) — fd 1 is stdout
	syscall.Write(1, msg)

	// exit(code=0)
	syscall.Exit(0)
}

That’s it. No fmt, no os.Stdout. The syscall.Write function maps directly to the Linux write(2) system call. File descriptor 1 is stdout by convention — that’s a kernel-level concept, not a Go one.

Going lower: using syscall.Syscall directly

The syscall.Write wrapper is convenient, but we can go one level deeper and invoke the syscall by number:

package main

import (
	"unsafe"
	"syscall"
)

func main() {
	msg := []byte("hello from the raw syscall\n")

	// SYS_WRITE = 1 on amd64 Linux
	// Arguments: fd, pointer to buffer, length
	syscall.Syscall(
		syscall.SYS_WRITE,
		1, // stdout
		uintptr(unsafe.Pointer(&msg[0])),
		uintptr(len(msg)),
	)

	// SYS_EXIT_GROUP = 231 on amd64 Linux
	syscall.Syscall(
		syscall.SYS_EXIT_GROUP,
		0, // exit code
		0,
		0,
	)
}

Here we’re calling syscall.Syscall with the raw syscall number. On x86-64 Linux, SYS_WRITE is 1 and SYS_EXIT_GROUP is 231. These numbers are defined by the kernel’s ABI and you can find them in /usr/include/asm/unistd_64.h or in Go’s syscall package constants.

The unsafe.Pointer usage is necessary because the kernel expects a memory address, not a Go slice.

Reading a file the kernel way

Now let’s try something more practical: reading a file using open, read, and close syscalls.

package main

import (
	"syscall"
	"unsafe"
)

func writeStdout(msg []byte) {
	syscall.Syscall(
		syscall.SYS_WRITE,
		1,
		uintptr(unsafe.Pointer(&msg[0])),
		uintptr(len(msg)),
	)
}

func main() {
	// Open /etc/hostname
	path := "/etc/hostname\x00" // must be null-terminated for the kernel
	fd, _, errno := syscall.Syscall(
		syscall.SYS_OPENAT,
		// AT_FDCWD means "relative to current directory"
		uintptr(0xFFFFFF9C), // AT_FDCWD = -100 as unsigned
		uintptr(unsafe.Pointer(unsafe.StringData(path))),
		syscall.O_RDONLY,
	)
	if errno != 0 {
		writeStdout([]byte("failed to open file\n"))
		syscall.Exit(1)
	}

	// Read up to 256 bytes
	buf := make([]byte, 256)
	n, _, errno := syscall.Syscall(
		syscall.SYS_READ,
		fd,
		uintptr(unsafe.Pointer(&buf[0])),
		uintptr(len(buf)),
	)
	if errno != 0 {
		writeStdout([]byte("failed to read file\n"))
		syscall.Exit(1)
	}

	// Write the contents to stdout
	writeStdout(buf[:n])

	// Close the file descriptor
	syscall.Syscall(syscall.SYS_CLOSE, fd, 0, 0)
}

Notice the null-terminated string (\x00). That’s a C convention that the kernel expects. Go strings aren’t normally null-terminated, so you have to add it yourself when passing paths directly to syscalls.

What about golang.org/x/sys/unix?

For anything beyond a learning exercise, you’ll want golang.org/x/sys/unix instead of the syscall package. It’s actively maintained, has better coverage of Linux-specific syscalls, and provides typed wrappers that are harder to misuse:

package main

import "golang.org/x/sys/unix"

func main() {
	unix.Write(1, []byte("hello via x/sys/unix\n"))

	// Get kernel info via uname(2)
	var utsname unix.Utsname
	if err := unix.Uname(&utsname); err == nil {
		sysname := unix.ByteSliceToString(utsname.Sysname[:])
		release := unix.ByteSliceToString(utsname.Release[:])
		unix.Write(1, []byte("Running: "+sysname+" "+release+"\n"))
	}

	unix.Exit(0)
}

This calls uname(2), the same syscall that powers the uname -r command. It returns the kernel name and version directly from the kernel.

Things I found interesting along the way

Syscall numbers are architecture-specific. SYS_WRITE is 1 on x86-64 but 64 on ARM64. Go handles this through build tags and platform-specific files, but if you’re writing raw syscall numbers, you need to know your target architecture.

Go’s runtime is always there. Even when you avoid the standard library, the Go runtime is still running — the garbage collector, the goroutine scheduler, all of it. If you wanted a truly minimal binary, you’d need to use something like GOEXPERIMENT=noruntime (experimental) or write assembly stubs. For most purposes the runtime overhead is irrelevant, but it’s good to know it’s there.

Error handling is errno, not error. At the syscall level, errors are returned as errno values (integers), not Go error interfaces. A non-zero errno means something went wrong, and you can check what against the constants in syscall (e.g. syscall.ENOENT for “file not found”).

File descriptors are just integers. 0 is stdin, 1 is stdout, 2 is stderr. Everything in Linux is a file — sockets, pipes, devices — and they’re all represented by integer file descriptors that you get from open, socket, pipe, etc.

Why bother with any of this?

You probably shouldn’t write production code this way. But understanding syscalls gives you a much better mental model for:

  • Debugging: when something fails at the OS level, you’ll know what’s actually happening
  • Performance: sometimes the standard library adds overhead you don’t need
  • Linux tooling: tools like strace make a lot more sense when you know what syscalls are
  • Other languages: these concepts transfer directly to C, Rust, Zig, or anything else that runs on Linux

Try running strace on your Go binary (strace ./myprogram) and you’ll see every syscall your program makes. It’s a great way to see what the Go runtime does behind the scenes.

What’s next

I’m planning to explore a few related topics:

  • Using mmap to map files into memory from Go
  • Writing a minimal HTTP server using only socket syscalls
  • Interacting with Linux namespaces and cgroups from Go (the building blocks of containers)

If you’re interested in this kind of low-level stuff, the Linux man pages (man 2 write, man 2 open, etc.) are surprisingly readable and are the definitive reference for every syscall.