Skip to content
academia.sh

Lesson 12 / 16

Cross-Compilation

It is measured whether the same portable source compiles for three separate targets with a single command, with none of those targets installed; compilability here too is a tool fact that requires no run. A second measurement shows that a platform-dependent call's compilability changes across the same three targets. The limiting measurement shows, by modeling, that being compiled does not mean it will run on the target — that an assumption carrying the same name can hold for one target family and fail for another.

Published

In this lesson

  • Reads from the tool whether the same portable source compiles for three separate targets, with no target machine installed
  • Measures and compares how a platform-dependent call's compilability changes across the same three targets
  • Bounds the claim that being compiled means it will run on the target, by modeling an assumption
Contents

The previous lesson showed that which file in a directory enters the build can be read from the tool alone, without running any program. This lesson carries the same question one step further: once the files are settled, which target do they compile for? The answer again comes without waiting for a run, but this time it carries its own limit along with it — being compiled does not mean it will run on that target.

One Command, Three Targets

Cross-compilation is the process of producing a binary for a target other than the machine the source is built on. The language’s own compiler does this job with a single command; what selects the target is two environment variables — one sets the target operating system family, the other the processor architecture. Both variables come from outside the build command, just like the build tags of the previous lesson; the difference is that a tag’s name is specific to the source, while these two variables’ names and possible values are fixed by the language itself. No third, target machine is needed: the compiler, independent of the machine it runs on, produces a binary for the requested target, and whether it can produce one is again information that falls into the tool class — learned without running anything.

This draws on the same underlying mechanism as the previous lesson. The standard library itself keeps many of its functions in a separate file per target family, behind a build tag carrying that family’s name (or an equivalent file-naming convention); the compiler reads which target it is working for from the two environment variables and picks the right file set. The difference is scale: a selection set up by hand in a six-file example in the previous lesson runs, in the standard library itself, across thousands of files, separately for each target. This is why a target does not need to be “installed” — all that is needed is for the compiler to know which source files are valid for that target, and this knowledge already sits in the source itself, in the file names and the tags.

The measurement below compiles a portable source containing no platform-specific call at all for three separate targets and reads whether each attempt passes. The produced binary is never written anywhere — the output is redirected to the system’s null device, because the only thing measured in this lesson is whether the build succeeds, not the produced file itself.

// helpers.go — the helpers this lesson shares: setting up a temporary package and calling the tool
package main

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

// show prints a fact, who states it, and its evidence on a single line.
func show(info, speaker, evidence string) {
	fmt.Printf("%-42s %-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("", "cross")
	if err != nil {
		return ""
	}
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module cross\n\ngo 1.24\n"), 0o644)
	for name, content := range files {
		os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
	}
	return dir
}

// compiles attempts to build the source for the given target; the output is never printed,
// only whether it succeeded is returned. The produced binary is written to the null device.
func compiles(dir string, extraEnv []string) bool {
	cmd := exec.Command("go", "build", "-o", os.DevNull, ".")
	cmd.Dir = dir
	cmd.Env = append(append(os.Environ(), "GOTOOLCHAIN=local", "CGO_ENABLED=0"), extraEnv...)
	return cmd.Run() == nil
}
// threeTargets.go — a portable source compiles for three separate targets, with no target machine needed
package main

import "fmt"

func main() {
	dir := setupModule(map[string]string{
		"portable.go": "package main\n\nfunc add(a, b int) int { return a + b }\n\nfunc main() {\n\t_ = add(1, 2)\n}\n",
	})
	defer func() { _ = dir }()

	targets := []struct{ name, goos, goarch string }{
		{"first target", "linux", "amd64"},
		{"second target", "windows", "amd64"},
		{"third target", "darwin", "arm64"},
	}

	fmt.Println("-- one command, three targets: portable source --")
	for _, t := range targets {
		env := []string{"GOOS=" + t.goos, "GOARCH=" + t.goarch}
		show(t.name+" compiled", "tool", fmt.Sprint(compiles(dir, env)))
	}
}
-- one command, three targets: portable source --
first target compiled                      tool    true
second target compiled                     tool    true
third target compiled                      tool    true

BD6. All three separate targets compiled, and this machine held no real copy of any of the three — the only thing run was the compiler itself, called three times with two environment variables. The result again leaves the run column entirely empty: which targets a portable source compiles for, without touching any of those targets, is read from a single command’s pass-or-fail answer.

Same Call, Changing Result

The source above contained nothing platform-specific, which is why all three targets gave the same answer. This uniformity breaks once a call binds to a platform-specific system function — some operating system families define that function, some do not define it at all. The measurement below tries, on the same three targets, a source that uses a single system call from the standard library specific to one operating system family (the named-pipe creation call).

// systemCall.go — same call, three targets: a platform-dependent system call
package main

import "fmt"

func main() {
	dir := setupModule(map[string]string{
		"caller.go": "package main\n\nimport \"syscall\"\n\nfunc main() {\n\t_ = syscall.Mkfifo\n}\n",
	})
	defer func() { _ = dir }()

	targets := []struct{ name, goos, goarch string }{
		{"first target", "linux", "amd64"},
		{"second target", "windows", "amd64"},
		{"third target", "darwin", "arm64"},
	}

	fmt.Println("-- same call, three targets: a platform-dependent system call --")
	succeeded := 0
	for _, t := range targets {
		env := []string{"GOOS=" + t.goos, "GOARCH=" + t.goarch}
		c := compiles(dir, env)
		if c {
			succeeded++
		}
		show(t.name+" compiled", "tool", fmt.Sprint(c))
	}
	show("how many of three targets compiled", "tool", fmt.Sprint(succeeded))
}
-- same call, three targets: a platform-dependent system call --
first target compiled                      tool    true
second target compiled                     tool    false
third target compiled                      tool    true
how many of three targets compiled         tool    2

BD7. Same source, same three targets, but this time the result is not uniform: two targets compile, one does not. The standard library’s system call package carries a separate implementation for each operating system family, and calling a function that does not exist at all in one family stops the build the moment that family is targeted — and this sits one level deeper than the “which file enters the build” question measured in the previous lesson, not in the same layer: the file itself enters the build list for all three targets, but a name inside that file is defined in two of the three targets and undefined in one. Which family defines which call is, again, tool knowledge: a person might know this by heart, but the build itself verifies it the same way every time, requiring no run.

The practical consequence of this difference is that a source that must support several targets at once needs to be written one of two ways. Either the platform-specific call is never used at all — like the portable source in the previous section, giving the same behavior on all three targets — or the platform-specific part is split into its own file, behind its own tag, and a separate implementation is written for each target. The second path carries the same shape as the previous lesson’s expansion/trial example: a file name or a tag decides which implementation takes effect on which target. The difference is that this time the choice is made automatically by the build command’s own target information, not by a manually supplied flag. A single file compiling on two of three targets and failing on the third is therefore not a flaw — it is a sign that the source supplies no alternative file at all for the targets that do not support that call.

Limiting Measurement: Compiled Is Not the Same as Working

Both measurements above stayed within the same boundary: whether a source compiles for a given target. This is a separate question from whether that source runs on that target, and this lesson genuinely cannot answer that second question — two of the three targets have no real copy on this machine, the produced binary cannot be run. Rather than hide this limit, we model it openly: the measurement below is never run on a real target at all, two target families’ environments are set up by hand inside the source.

// assumptionModel.go — limiting measurement: compiled is not the same as working, shown through a model
package main

import "fmt"

// findHomePath looks up the home directory in the given environment map by a SINGLE variable name.
func findHomePath(env map[string]string, variableName string) (string, bool) {
	value, found := env[variableName]
	return value, found
}

func main() {
	// two target families' environments are modeled here by hand, without ever really running:
	// each family offers a variable carrying the user's home directory under its own name.
	firstFamilyEnv := map[string]string{"HOME": "user-directory"}
	secondFamilyEnv := map[string]string{"USERPROFILE": "user-directory"}

	// despite the portable-looking name, the code searches for only ONE variable name.
	searchedVariable := "HOME"

	_, firstFound := findHomePath(firstFamilyEnv, searchedVariable)
	_, secondFound := findHomePath(secondFamilyEnv, searchedVariable)

	fmt.Println("-- limiting measurement: compiled is not the same as working (model) --")
	show("home path found in first target family", "tool", fmt.Sprint(firstFound))
	show("home path found in second target family", "tool", fmt.Sprint(secondFound))
}
-- limiting measurement: compiled is not the same as working (model) --
home path found in first target family     tool    true
home path found in second target family    tool    false

BD8. The findHomePath function contains no platform-specific call at all — it looks up a map by a single string key — which is why, if it had been compiled for three real targets like above, it would compile without trouble on all three. The flaw is not in the build, it is in the assumption: the code looks up only a single variable name, and this name is valid in the first target family, invalid in the second. In a real program this line might be a call trying to find a user’s settings directory; run on the second target family, it could silently return empty, with no build error and no runtime crash. This is why the “compiled=true” line is reliable information here too, just as in the previous two measurements — but it says what is true, not what works well. This distinction cannot be measured without modeling it, because a run itself never genuinely happens on a real target in this lesson.

The model itself was kept deliberately small: two maps, one string key, two lookups. In a real program this kind of assumption is usually buried much deeper — the directory a configuration file is searched in, the place a log file is written to, the name of a temporary file — and all of them share the same pattern: the code accepts a single name, a single path, or a single format as correct, this acceptance passes the build on all three targets, and it surfaces only on the target where it is wrong, only once that target is actually run. This is exactly where the power of the compilability measurement ends: what it says is true and reliable, but the question it asks is narrow — it only answers “does this source turn into a binary for this target,” not “does this binary do the right thing.”

The way this limit is closed in practice stays within the same three classes: the only reliable way to show that something truly works on a target is to genuinely run it there — on a real machine or in an environment simulating that target. The compilability test does not replace this step, it precedes it: if a source does not even compile for one of three targets, there is no need to try a run on that target at all. For a source that compiles on all three targets, the question is still open — the compilability test removes this open question early, at a cheap step, but it does not close it. In this lesson the second step (a real run) was never taken, because two of the three targets have no real copy on this machine; this is a limit of this measurement’s scope, not a limit of cross-compilation itself.

The same limit applies to one of cross-compilation’s own boundaries: when a source links to a native library through cgo, the compiler usually cannot find the external compiler it needs for the target, and the build is genuinely rejected this time — that is the subject of the course’s final lesson. The three targets measured in this lesson were all pure source; no external compiler was ever needed, and so none of them was ever rejected.

Summary

  • Cross-compilation is the process of producing a binary for a target with no target system environment installed at all, with a single command; compilability here too is a tool fact that requires no run.
  • A portable source gave the same answer on all three targets; once a single platform-dependent system call was added, only two of the same three targets compiled.
  • Which target family a call is defined in is also information read from the tool — the build verifies this the same way on every attempt, requiring no personal knowledge.
  • Limiting measurement: being compiled does not mean it will work correctly on the target; an assumption tied to the same name can hold for one target family and fail for another.
  • This limit was shown not by running on a real target but by openly modeling two targets’ environments inside the source; nowhere in this lesson was a real target machine ever touched.

Next Step

In this lesson the target was a choice under which the file itself never changed — the source was fixed, only the environment it compiled for changed. The next lesson takes up a value that is never written into the source at all, but still enters the binary: a version fact is placed into the binary not on any line of the source, but only at build time, by a separate tool. The question asked this time is not “which target” but “where does a value never written in the source come from” — and just as the target chosen in this lesson leaves no trace in the binary, that value leaves no trace in the source itself either; both are added to the binary only by the build process’s own tools, from outside the source.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close