---
title: 'C Interoperability'
source: 'https://academia.sh/en/courses/go-production/c-interoperability'
course: 'Production Go: Testing, Tooling and Performance'
language: en
updated: '2026-09-14T10:59:32+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "Compares two forms of the same task — the language's own code and a C function called through cgo — by allocation count"
    - 'Measures a package with a native library call for compilability with CGO_ENABLED=0 and for cross-compilation'
    - "Shows, by counting, that escape analysis produces no statement about the C function's body"
    - "Gathers the course's and the M09 Go curriculum's closing table from fifteen sibling lessons"
---

# C Interoperability

The same task is compared in two forms — the language's own code and a C function called through cgo — and both give the same allocation count (1). The package with the native call does not compile with CGO_ENABLED=0, does not cross-compile, and escape analysis makes no statement about the C function's body (0/21 lines). Limiting measurement: memory not freed on the C side shows up in neither the analyzer nor the allocation count — the cost is not in the call itself, but in what the tool can say. The course, and the M09 Go curriculum, close with this lesson through a sixteen-row table.

The previous lesson measured that an analyzer coming out clean does not mean flawless, only
that the searched pattern was absent — its silence was a limit of the rule list, not proof. This
lesson carries that limit further: once a Go program steps outside the language's boundary into
another language's code, the compiler, the analyzer, and the profiler can no longer always reach
it. The question is narrow: through which tool, with which number, does crossing that boundary's
cost become visible?

C here is not a product or a library brand — it is a language, its name written the same way
Go's own is. But the mechanism used throughout this lesson, cgo, carries a dependency the rest of
the course never carried: the source only compiles with a genuine C compiler present and
`CGO_ENABLED` on. **BD19.** This lesson carries that dependency as an open assumption; without a
compiler, or with `CGO_ENABLED` off, the blocks do not compile at all. None of the course's
fifteen sibling lessons needed this second chain.

## cgo: A Call That Crosses the Language's Boundary

The standard library opens this crossing through a pseudo-package: right before `import "C"`, a
gapless comment block carries genuine C source, which the compiler hands to a C compiler and
binds back to the Go side. The `native.go` below defines a small C function (`upperC`) that
upper-cases a string using `<string.h>` and `<ctype.h>`, called from two Go wrappers.

```go
// native.go — a native library wrapper calling a function written in C through cgo
package main

/*
#include <string.h>
#include <stdlib.h>
#include <ctype.h>

char* upperC(const char* s) {
	size_t n = strlen(s);
	char* result = (char*)malloc(n + 1);
	for (size_t i = 0; i < n; i++) {
		result[i] = toupper((unsigned char)s[i]);
	}
	result[n] = 0;
	return result;
}
*/
import "C"
import "unsafe"

// upperClean passes the string to the C side's upperC function and gets the result back;
// it frees the result buffer allocated on the C side.
func upperClean(s string) string {
	cs := C.CString(s)
	defer C.free(unsafe.Pointer(cs))
	result := C.upperC(cs)
	defer C.free(unsafe.Pointer(result))
	return C.GoString(result)
}

// upperLeaking makes the same call but never frees the C side's result buffer.
func upperLeaking(s string) string {
	cs := C.CString(s)
	defer C.free(unsafe.Pointer(cs))
	result := C.upperC(cs)
	return C.GoString(result)
}
```

`C.CString` copies the given Go string with **C's own allocator** (`malloc`) — a region Go's
garbage collector never tracks. `C.GoString` works the other way: it reads bytes on the C side
and builds a new string on Go's heap. `upperClean` frees both C buffers (`cs`, `result`) with
`C.free`; `upperLeaking` never frees the second — measured in a later section.

The rest of this lesson reuses the same two routines the previous lessons shared: `setupModule`
sets up a temporary module, `runTool` runs the language's own command over it.

```go
// tools.go — the helpers this lesson shares: setting up a temporary package, calling the tool, running five times
package main

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"testing"
)

// show prints a fact, who states it, and its evidence on a single line.
func show(info, speaker, evidence string) {
	fmt.Printf("%-48s %-7s %s\n", info, speaker, evidence)
}

// setupModule writes the given files into a new temporary module; the path is never printed.
func setupModule(files map[string]string) string {
	dir, err := os.MkdirTemp("", "nativelibrary")
	if err != nil {
		return ""
	}
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module nativetrial\n\ngo 1.24\n"), 0o644)
	for name, content := range files {
		os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
	}
	return dir
}

// runTool runs the language's own command, returning whether the exit succeeded; output is never printed.
func runTool(dir string, extraEnv []string, arg ...string) (string, bool) {
	cmd := exec.Command("go", arg...)
	cmd.Dir = dir
	cmd.Env = append(append(os.Environ(), "GOTOOLCHAIN=local"), extraEnv...)
	output, err := cmd.CombinedOutput()
	return string(output), err == nil
}

// fiveRuns runs the same benchmark five times, returning the first value and how
// many distinct values were seen — the course's stability pattern for proving a number.
func fiveRuns(f func(*testing.B)) (int64, int) {
	values := map[int64]bool{}
	var first int64
	for i := 0; i < 5; i++ {
		r := testing.Benchmark(f)
		values[r.AllocsPerOp()] = true
		if i == 0 {
			first = r.AllocsPerOp()
		}
	}
	return first, len(values)
}
```

## Same Task, Two Forms: Allocation Count

The pattern from the course's Benchmarking lesson continues unchanged: two forms of
the same task, each across five runs, compared only by allocation count — one the language's own
code (`strings.ToUpper`), the other a native library call through `upperClean`.

```go
// measurement1.go — same task, two forms: the language's own code and a native library call, compared by allocation count
package main

import (
	"fmt"
	"strings"
	"testing"
)

var sampleString = "screw-nut-washer"
var sink string

//go:noinline
func benchGo(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sink = strings.ToUpper(sampleString)
	}
}

//go:noinline
func benchNative(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sink = upperClean(sampleString)
	}
}

func main() {
	fmt.Println("-- same task, two forms: allocation count --")
	goAllocs, goDistinct := fiveRuns(benchGo)
	nativeAllocs, nativeDistinct := fiveRuns(benchNative)
	show("language's own code (strings.ToUpper)", "run", fmt.Sprint(goAllocs, " distinct value across five runs=", goDistinct))
	show("native library call (cgo)", "run", fmt.Sprint(nativeAllocs, " distinct value across five runs=", nativeDistinct))
}
```

```
-- same task, two forms: allocation count --
language's own code (strings.ToUpper)            run     1 distinct value across five runs=1
native library call (cgo)                        run     1 distinct value across five runs=1
```

**BD20.** Both forms reach the same number, one, unchanged across five runs. This could read as
"the native call has no cost," but `upperClean`'s body does two things `strings.ToUpper` does
not: it copies the string into C's allocator (`CString`), allocates and fills a second buffer on
the C side (`upperC`), and frees both. The single `1` only counts the final copy `GoString`
writes onto Go's heap — C's `malloc` calls never enter this number, since the measuring tool
tracks only **its own** heap.

## The Tool Knowledge a Native Call Costs

How much of what the course writes to the **tool** class — compilability, cross-compilation, the
compiler's decision — survives once a native call is added? The measurement below writes
`nativeSource` to a temporary module and asks three things: does it compile with
`CGO_ENABLED=0`, does it cross-compile for a platform with no copy (`linux/arm64`), and how many
escape diagnostics belong to the Go side versus `upperC`.

```go
// measurement2.go — the tool knowledge a native call costs: compilability, cross-compilation, escape boundary
package main

import (
	"fmt"
	"os"
	"strings"
)

const nativeSource = "package main\n\n/*\n#include <string.h>\n#include <ctype.h>\n#include <stdlib.h>\n\nchar* upperC(const char* s) {\n\tsize_t n = strlen(s);\n\tchar* result = (char*)malloc(n + 1);\n\tfor (size_t i = 0; i < n; i++) result[i] = toupper((unsigned char)s[i]);\n\tresult[n] = 0;\n\treturn result;\n}\n*/\nimport \"C\"\nimport \"unsafe\"\n\nfunc upperNative(s string) string {\n\tcs := C.CString(s)\n\tdefer C.free(unsafe.Pointer(cs))\n\tresult := C.upperC(cs)\n\tdefer C.free(unsafe.Pointer(result))\n\treturn C.GoString(result)\n}\n\nfunc main() {\n\t_ = upperNative(\"x\")\n}\n"

func main() {
	dir := setupModule(map[string]string{"native.go": nativeSource})

	fmt.Println("-- the tool knowledge a native call costs --")
	_, disabledCompiled := runTool(dir, []string{"CGO_ENABLED=0"}, "build", "-o", os.DevNull, ".")
	show("compiled with CGO_ENABLED=0", "tool", fmt.Sprint(disabledCompiled))

	_, crossCompiled := runTool(dir, []string{"CGO_ENABLED=1", "GOOS=linux", "GOARCH=arm64"}, "build", "-o", os.DevNull, ".")
	show("compiled for the linux/arm64 target", "tool", fmt.Sprint(crossCompiled))

	output, _ := runTool(dir, []string{"CGO_ENABLED=1"}, "build", "-gcflags=-m", "-o", os.DevNull, ".")
	lines := strings.Split(strings.TrimSpace(output), "\n")
	goSide := 0
	for _, l := range lines {
		if strings.Contains(l, "native.go") {
			goSide++
		}
	}
	cSide := strings.Count(output, "upperC")
	show("total escape/compile diagnostic lines", "tool", fmt.Sprint(len(lines)))
	show("diagnostics tied to a native.go line (Go side)", "tool", fmt.Sprint(goSide))
	show("diagnostics tied to upperC's body (C side)", "tool", fmt.Sprint(cSide))
}
```

```
-- the tool knowledge a native call costs --
compiled with CGO_ENABLED=0                      tool    false
compiled for the linux/arm64 target              tool    false
total escape/compile diagnostic lines            tool    21
diagnostics tied to a native.go line (Go side)   tool    11
diagnostics tied to upperC's body (C side)       tool    0
```

**BD21.** The package does not compile with `CGO_ENABLED=0`: a file carrying `import "C"` never
enters the build once cgo is off — another form of the Build Tags lesson's "a file stays outside
the build," this time chosen by an environment variable, not a comment line. **BD22.** The
Cross-Compilation lesson measured that a source with no platform-specific call compiles on all
three targets; here the uncopied `linux/arm64` build fails, because what is needed is not Go's
own cross-compiler but that target's **own** C compiler, missing from this environment.

**BD23.** Eleven of twenty-one escape diagnostics tie back to a line in `native.go`. **None** —
zero — carry `upperC`'s name: not because the compiler ignores this function, but because it
genuinely **cannot see** it — escape analysis runs over Go's own syntax tree, and `upperC`'s body
is C. The compiler decides up to the boundary (the `cs` and `result` pointers) and asks nothing
beyond it.

## Limiting Measurement: The Cost Is Not in the Call Itself

The measurements above point the same way: escape analysis does not see past the boundary,
compilability depends on a C compiler, allocation count counts only Go's own heap. This final
measurement joins all three into a sharper example: the only difference between `upperClean` and
`upperLeaking` is whether the C-side result buffer gets freed — a genuine leak in C's own heap.

```go
// boundary.go — limiting measurement: the cost is not in the call itself, but in what the tool can say
package main

import (
	"fmt"
	"testing"
)

const leakingSource = "package main\n\n/*\n#include <string.h>\n#include <ctype.h>\n#include <stdlib.h>\n\nchar* upperC(const char* s) {\n\tsize_t n = strlen(s);\n\tchar* result = (char*)malloc(n + 1);\n\tfor (size_t i = 0; i < n; i++) result[i] = toupper((unsigned char)s[i]);\n\tresult[n] = 0;\n\treturn result;\n}\n*/\nimport \"C\"\nimport \"unsafe\"\n\nfunc upperLeaking(s string) string {\n\tcs := C.CString(s)\n\tdefer C.free(unsafe.Pointer(cs))\n\tresult := C.upperC(cs)\n\treturn C.GoString(result)\n}\n\nfunc main() {\n\t_ = upperLeaking(\"x\")\n}\n"

var sink string
var sampleString = "screw-nut-washer"

//go:noinline
func benchClean(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sink = upperClean(sampleString)
	}
}

//go:noinline
func benchLeaking(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sink = upperLeaking(sampleString)
	}
}

func main() {
	fmt.Println("-- limiting measurement: does a memory leak show up when run --")
	cleanAllocs, cleanDistinct := fiveRuns(benchClean)
	leakingAllocs, leakingDistinct := fiveRuns(benchLeaking)
	show("allocation count of the freeing version", "run", fmt.Sprint(cleanAllocs, " distinct value across five runs=", cleanDistinct))
	show("allocation count of the leaking version", "run", fmt.Sprint(leakingAllocs, " distinct value across five runs=", leakingDistinct))

	dir := setupModule(map[string]string{"native.go": leakingSource})
	_, clean := runTool(dir, []string{"CGO_ENABLED=1"}, "vet", "./...")
	show("analyzer finds the leaking version clean too", "tool", fmt.Sprint(clean))
}
```

```
-- limiting measurement: does a memory leak show up when run --
allocation count of the freeing version          run     1 distinct value across five runs=1
allocation count of the leaking version          run     1 distinct value across five runs=1
analyzer finds the leaking version clean too     tool    true
```

**BD24.** The two numbers are identical — freeing and leaking both give one allocation, a single
value across five runs. Because the allocation count tracks only Go's heap, the leak growing on
the C side is never visible in it. The analyzer gives the same silence: `vet` finds the leaking
version clean too, since none of its patterns cover failing to free a C call's result — nor could
it, the leak sitting inside `upperC`'s body, entirely outside Go's syntax. The course's measuring
axis reaches its own boundary here: all three answers (source, tool, run) stayed within one
boundary, never reaching the C compiler's decisions beyond it. The cost is not in the call
itself, but in what the tool **can say**: neither compiler, analyzer, nor profiler sees past the
boundary.

## Course Wrap-Up

This course carried a single question across sixteen lessons: who states a fact about a program —
the source, a tool, or a run? The shared definition's three claims added three layers. First,
most of what can be known about a program is not written in the source: only two of eleven facts
sit in the text, the rest come from a tool or a run. Second, tool and run knowledge do not carry
the same reliability — a tool needs no run and gives the same answer every call, while run
knowledge must be separately tested for staying the same. Third, specific to this course: of the
the benchmark's two numbers, only the allocation count is repeatable; duration was
never written, printed, or measured.

This final lesson carried the same three classes past the language's boundary: escape analysis
says nothing about the C function's body, the analyzer does not see a leak in C, the allocation
count does not count C's allocator. The course's reading of "a tool speaking is not the same as
speaking truly" reaches its sharpest form here: past the boundary, both tool and run fall silent,
and that silence is not proof of flawlessness — it is proof of a place the tool never looked.

The course closes without overreaching: what was measured was a single native library call, not
the whole of cgo's machinery; the same boundary can show up with a different tool at a different
crossing. The M09 Go curriculum closes here too, and its five courses' five questions grow from
the same root: Go Fundamentals asked what stands in for the unwritten — defined, rejected, or
left open; Interfaces, Errors and Generics asked where a behavior's binding to a type is
established; Concurrency asked how a goroutine stops; The Standard Library and Network
Applications asked what happens to a value's type information at a boundary; this course asked
which source the knowledge comes from. All five converge on one observation: knowing a program
takes more than reading its source.

| Lesson | Fact measured | Speaker | Limiting measurement |
|---|---|---|---|
| Test Package Basics | Whether the test passed | run | A passing test proves only what it tested, not correctness |
| Table-Driven Tests | Whether adding a case changed coverage | run | The sixth case left coverage fixed at 100% |
| HTTP Tests | Which status code a request turned into | run | The request address is fixed — the handler is tested, not the network |
| Faking and Interface Separation | The interface separation that makes faking possible | source | Under excessive reduction the real implementation rejected, the fake accepted |
| Benchmarking | The distribution of eleven facts, allocation count | source · tool · run (2/5/4) | Per-operation and total allocation are separate; conflating them misleads |
| Coverage Reporting | The coverage ratio three test sets gave | run | An uninspecting case gave 100% with flawed source too |
| Profiling | The allocation count of two call forms | run | A value allocated on a never-run branch is invisible in the profile |
| Escape Analysis | The decision whether a value is allocated on the stack or the heap | tool | Not the language's promise — the compiler's own implementation detail |
| Garbage Collection Behavior | The effect of three techniques on allocation count | run | A readable and an unreadable form reached the same number |
| Tracing | The event count of two setups | run | Collecting a trace changes behavior; numbers are read within that limit |
| Build Tags | Which files enter the build | tool | An outside file is not deleted, it stays unseen until tagged |
| Cross-Compilation | Whether the source compiles for three targets | tool | Compiled does not mean it runs — the assumption depends on the target |
| Linker Options | A sourceless value embedded into the binary | tool | The linker never validates the embedded value, it accepts emptiness too |
| Module Publishing | Whether the version is compatible at the signature level | source · tool | Same signature, different result — the promise is in signatures, not behavior |
| Static Analysis and Security Scanning | Whether the difference was caught by the analyzer | tool | A clean-looking pattern crashed when run — clean is not flawless |
| C Interoperability | Allocation count, compilability, escape visibility of a native call | tool · run | Leaked C memory shows up in neither the analyzer nor the allocation count |

## Summary

- Two forms — the language's own code, a C function called through cgo — reached the same
  allocation count (one), but this equality never counted the two `malloc` calls on the C side.
- A package with a native call did not compile with `CGO_ENABLED=0` and did not cross-compile for
  a target with no C compiler; the lesson carried this dependency as an open assumption.
- Eleven of twenty-one escape diagnostics tied to the Go side, zero to the C function's body — the
  compiler cannot see past the boundary, it does not ignore it.
- Limiting measurement: memory not freed on the C side shows up in neither the analyzer nor the
  allocation count; the cost is not in the call itself, it is in what the tool can say.
- The M09 Go curriculum closes with this lesson; the five courses' five questions — what stands in
  for the unwritten, where the binding sits, how a goroutine stops, type information at a
  boundary, who states this fact — converge on one observation: knowing a program takes more than
  reading its source.
