# ebitengine/purego: calling C from Go without Cgo, and where that stops working

> purego replaces the C compiler with hand-written assembly stubs so a Go binary can dlopen a shared library and call into it. It is beta software with a documented platform tier list, and the tier list is the part that decides whether you can use it.

**ebitengine/purego** — A library for calling C functions from Go without Cgo

- Repository: https://github.com/ebitengine/purego
- Stars: 3,990 · Forks: 133
- Language: Assembly
- License: Apache-2.0
- Published: 2026-09-23 · Updated: 2026-09-23 · Language: en
- Canonical page: https://hysenlabs.com/projects/ebitengine-purego

## The problem purego solves is the C compiler, not C itself

Cgo is the standard way to reach a C library from Go, and it works. The cost is that it makes the C toolchain part of your build. Cross-compiling to Windows from Linux stops being a matter of setting GOOS and starts being a matter of having a cross-compiler that matches the target, plus the headers for whatever library you are linking. The README describes the origin of the project directly: the Ebitengine game engine was ported to use only Go on Windows, which made cross-compilation from any other operating system a matter of setting GOOS=windows, and purego was born to bring that same approach to the other platforms Ebitengine supports.

That framing tells you who this is for. It is for Go developers who ship to multiple operating systems and do not want a C compiler in the release pipeline, and for anyone who wants to load a shared object at runtime rather than link against it at build time. The README lists the benefits as simple cross-compilation, faster compilation through caching entirely Go builds, and smaller binaries, with the explanation that Cgo generates a C wrapper function for each C function called while purego does not. It also lists a Cgo fallback that works even with CGO_ENABLED=1, which matters for incremental porting: you can move one call site at a time instead of rewriting a package.

The README labels the project beta software and warns of bugs and potentially API breaking changes, with the mitigation that each release will be tagged to avoid breaking people's code. That is an honest description of the risk. It is not a project that promises ABI stability across minor versions.

## How purego calls a C function: dlopen, RegisterLibFunc, and per-architecture struct files

The mechanism has two halves. The first is dynamic loading, exposed through Dlopen and the RTLD_NOW and RTLD_GLOBAL flags, which map onto the platform loader: dlfcn_darwin.go, dlfcn_linux.go, dlfcn_freebsd.go, dlfcn_netbsd.go and dlfcn_android.go exist as separate files, and there are dlfcn_nocgo_* variants for the platforms that ship their own loader shim. The second half is the call itself. RegisterLibFunc takes the address of a Go function variable and a symbol name, and wires that symbol up so the Go function value can be invoked.

The part that replaces the C compiler is the assembly. The repository root holds sys_amd64.s, sys_arm64.s, sys_386.s, sys_arm.s, sys_loong64.s, sys_ppc64le.s, sys_riscv64.s and sys_s390x.s, plus sys_unix_* counterparts. These are the stubs that move arguments into registers according to the platform ABI and jump into the loaded function. Struct handling is separate and also per-architecture: struct_amd64.go, struct_arm.go, struct_arm64.go, struct_loong64.go, struct_ppc64le.go, struct_riscv64.go and struct_s390x.go, with struct_other.go as the fallback. The abi_amd64.h, abi_arm64.h and abi_loong64.h headers are copied from the Go runtime's runtime/cgo package, which the README documents along with the rest of the copied files.

The interesting design consequence is that purego has to reimplement, per architecture, the same classification rules that the C compiler applies when it decides which arguments go in registers and which go on the stack. That is why the support matrix is granular and why struct-by-value support differs by target. The README states that on some architectures structs can be passed by value as arguments and return values when calling C, but not in callbacks created with NewCallback, and that on others structs by value are not supported at all. The internal/fakecgo directory exists for the platforms that need a cgo-shaped runtime without the C compiler; the README notes its files are copied from runtime/cgo and that go_GOOS.go files were modified from gcc_GOOS_GOARCH.go.

## Installing purego and making a first call into libc

There is no installer and no binary. purego is a Go module, so the install step is adding it to a module. The repository's go.mod declares the module path github.com/ebitengine/purego and requires Go 1.25.0.

```bash
go get github.com/ebitengine/purego
```

The README gives a complete example that loads the system C library and calls puts. It handles only macOS and Linux, and the README points to examples/libc for FreeBSD and Windows, which need different handling. The library path is selected by GOOS, then Dlopen returns a handle, then RegisterLibFunc binds the symbol name to a Go function variable.

```go
package main

import (
	"fmt"
	"runtime"

	"github.com/ebitengine/purego"
)

func main() {
	libc, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", purego.RTLD_NOW|purego.RTLD_GLOBAL)
	if err != nil {
		panic(err)
	}
	var puts func(string)
	purego.RegisterLibFunc(&puts, libc, "puts")
	puts("Calling C from Go without Cgo!")
}
```

The README's run command disables Cgo explicitly, which is the whole point of the exercise. If you leave CGO_ENABLED at its default of 1, you are not testing the path you think you are testing.

```bash
CGO_ENABLED=0 go run main.go
```

On the platforms marked in the support notes as requiring the fakecgo flag, compilation with CGO_ENABLED=0 needs an extra gcflags setting. The README gives it exactly as follows, and it applies to FreeBSD amd64/arm64 and NetBSD amd64/arm64.

```bash
go build -gcflags="github.com/ebitengine/purego/internal/fakecgo=-std"
```

What you should see from the first example is the string printed by libc's puts. If Dlopen returns an error instead, the library name is wrong for the platform; the README's example uses /usr/lib/libSystem.B.dylib on darwin and libc.so.6 on linux.

## The tier list is the real constraint, and Tier 2 is where projects break

The README splits platforms into two tiers with different promises. Tier 1 is Android amd64 and arm64, iOS amd64 and arm64, Linux amd64 and arm64, macOS amd64 and arm64, and Windows amd64 and arm64. Critical bugs on Tier 1 are treated as release blockers and the release is postponed until they are fixed. Tier 2 is best effort, and critical bugs there do not block a release. Tier 2 covers Android 386 and arm, FreeBSD amd64 and arm64, Linux 386, arm, loong64, ppc64le, riscv64 and s390x, NetBSD amd64 and arm64, and Windows 386 and arm.

The support notes are where the practical limits live, and they are worth reading before you pick a target. Android amd64, arm64, iOS amd64 and arm64 require CGO_ENABLED=1 to compile, which means the Cgo fallback is doing the work and you have not removed the C compiler from those builds. Linux loong64, ppc64le and Windows amd64 and arm64 support passing structs by value as arguments and return values, but not in callbacks created with NewCallback. Linux 386, arm, riscv64, s390x, FreeBSD, NetBSD, Android 386 and arm, and Windows 386 and arm do not support passing structs by value at all. FreeBSD and NetBSD need the fakecgo gcflags flag shown above. Linux s390x needs CGO_ENABLED=1 in versions before Go 1.27. Windows arm is no longer supported as of Go 1.26.

Two of those notes are the kind of thing that turns into a bug report at the worst moment. If your C API takes a struct by value and you are on a Tier 2 architecture that does not support it, purego is the wrong tool and no amount of configuration fixes it. If your design depends on C calling back into Go with a struct argument, the NewCallback restriction rules out the platforms where structs work in the forward direction. The README also notes that unsupported GOARCHs, naming freebsd/riscv64 and linux/mips, still work through the Cgo fallback except for float arguments and return values. That exception is easy to miss and produces wrong numbers rather than an error.

## purego vs cgo: what actually differs in the build and the binary

The comparison people search for is purego versus cgo, and the difference is not performance, it is where the work happens. Cgo resolves symbols at link time and generates a C wrapper per function, which is why the README says purego produces smaller binaries. purego resolves symbols at runtime through Dlopen and RegisterLibFunc, which is why it can be used as a plugin system and why a missing symbol is a runtime error rather than a link error. That shift is the trade: you lose the linker's ability to tell you at build time that a function does not exist.

The second difference is the toolchain. Cgo needs a C compiler for the target, which is the cross-compilation problem the README opens with. purego needs assembly stubs that already exist for your GOARCH, which is why the support matrix is a list of architectures rather than a list of operating systems. The third difference is the fallback path. The README states purego works with CGO_ENABLED=1, so the two are not mutually exclusive; you can keep cgo for the parts that need it and move individual call sites over. That is a genuine advantage for a large existing codebase, and it is also a warning: a project that only ever builds with CGO_ENABLED=1 has not actually validated the no-Cgo path, and the platform notes above show several targets that silently require it.

If you need a different kind of alternative, the objc/ directory and the examples/objc example show the same loading machinery aimed at Objective-C rather than plain C, and examples/protocol-dumper and examples/window show larger uses. These are in-repository examples, not separate projects.

## Licence, copied runtime code, and what upgrading costs

The repository is Apache-2.0. The README adds a detail that matters for anyone doing licence review: purego contains code originating from the Go runtime, and those files are under the BSD-3 licence found in the Go source. The README lists them explicitly, including abi_*.h from runtime/cgo, wincallback.go from runtime, the internal/fakecgo files copied from runtime/cgo, and the note that internal/fakecgo/go_GOOS.go files were modified from runtime/cgo/gcc_GOOS_GOARCH.go and that internal/fakecgo/linux.go combines runtime/cgo/linux.go with runtime/cgo/linux_syscall.c. The README also explains that abi_*.h and internal/fakecgo/abi_*.h are duplicated because Bazel does not support cross-package use of #include. If your organisation has rules about mixing licence terms in a single binary, that list is the thing to hand to whoever reviews it. This is a description of what the README says, not legal advice.

Upgrade cost is driven by the beta warning and the tagged releases. The releases are versioned and dated, and the README's promise is that each release is tagged to avoid breaking people's code, which is a weaker promise than API stability. The repository layout suggests the churn concentrates in the per-architecture files: the struct_GOARCH.go set, the sys_*.s set, and the internal/fakecgo directory. A change to argument classification on one architecture is a change to a file most users never open, which is good for them and bad for anyone who vendored a patched copy.

The go.mod requires Go 1.25.0, and one support note is tied to a Go version: Linux s390x needs CGO_ENABLED=1 before Go 1.27. So a Go toolchain upgrade can change which platforms compile without Cgo, in either direction. That is the upgrade cost to plan for.

## Conclusion

Adopt purego when you need GOOS=windows cross-compilation from a non-Windows host, or when you want a shared library loaded at runtime as a plugin, and your target is Tier 1: Linux amd64/arm64, macOS amd64/arm64, or Windows amd64/arm64. Do not adopt it if you pass structs by value on a Tier 2 architecture, if you need callbacks that take structs by value, or if you are on Windows arm, which the README says is no longer supported as of Go 1.26. Before committing, check three things in the repository: whether your GOARCH appears in the Tier 1 list, whether your platform needs the fakecgo -gcflags entry, and whether your C library's ABI actually matches what the struct_GOARCH.go files implement. The README carries a beta warning, so pin a tagged release rather than tracking main.

## FAQ

### What is purego?

It is a Go library for calling C functions without Cgo, developed for the Ebitengine game engine so that builds stay entirely Go and cross-compilation works without a C compiler. It loads shared libraries at runtime with Dlopen and binds symbols with RegisterLibFunc.

### How does purego compare to cgo in performance?

The README does not make performance claims. It compares the two on build characteristics instead: no C compiler needed for cross-compilation, faster compilation through caching entirely Go builds, and smaller binaries because Cgo generates a C wrapper function for each C function called while purego does not.

### Which platforms does purego support?

Tier 1 is Android amd64 and arm64, iOS amd64 and arm64, Linux amd64 and arm64, macOS amd64 and arm64, and Windows amd64 and arm64. Tier 2 is best effort and covers Android 386 and arm, FreeBSD amd64 and arm64, Linux 386, arm, loong64, ppc64le, riscv64 and s390x, NetBSD amd64 and arm64, and Windows 386 and arm.

### Can purego pass structs by value to a C function?

It depends on the architecture. The README states that Linux loong64, ppc64le and Windows amd64 and arm64 support structs by value as arguments and return values but not in callbacks created with NewCallback, while Linux 386, arm, riscv64 and s390x, FreeBSD, NetBSD, Android 386 and arm, and Windows 386 and arm do not support structs by value at all.

### Do I need CGO_ENABLED=0 to use purego?

No. The README states purego works with CGO_ENABLED=1 as a fallback, which allows incremental porting, and some platforms such as Android amd64 and arm64, iOS amd64 and arm64, and Linux s390x before Go 1.27 require CGO_ENABLED=1 to compile.

### What licence does purego use?

The repository is Apache-2.0. The README also documents that some files are copied from the Go runtime and remain under the BSD-3 licence found in the Go source, and it lists those files, including the abi_*.h headers and the internal/fakecgo files.

## Sources

- [ebitengine/purego on GitHub](https://github.com/ebitengine/purego)
- [Issues](https://github.com/ebitengine/purego/issues)
- [License: Apache-2.0](https://github.com/ebitengine/purego/blob/main/LICENSE)
- [README](https://github.com/ebitengine/purego/blob/main/README.md)
- [Releases](https://github.com/ebitengine/purego/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/ebitengine-purego
