Lesson 13 / 16
Linker Options
It is measured that a string value written nowhere in the source gets embedded into the binary in three separate forms across three separate build calls; the source text stays character-for-character identical across all three. The limiting measurement shows, by running it, that the embedded value is never validated by the linker — that an unformatted string is accepted just as silently — and that a separate linker option removing the symbol table does not change the program's output.
Published
In this lesson
- Measures how a string value never written in the source gets embedded into the binary in three different forms from the same source, across three separate calls
- Shows that the linker never validates the embedded value, silently accepting an unformatted input as well
- Distinguishes, by running it, that a linker option removing the symbol table does not change the program's output
Contents
In the previous lesson, the chosen target left no trace in the binary, determined only by the build process’s own tools. This lesson carries the same pattern forward, this time for a single string value instead of a directory or an architecture: a value written nowhere in the source enters the binary again at build time, again from outside the source. This time the party asking the question is not the compiler but the linker — the tool that runs in the final step of compiling the source, joining separately compiled parts into a single binary.
A Value Not in the Source
A package-level variable can be declared in the source with a default value, and this value can
be changed at build time with a linker flag. The measurement below runs a single source — a
single version variable and a main function printing it — through three separate build
calls. The source text stays character-for-character identical across all three; the only thing
that changes is a flag supplied to the command.
The flag itself carries an equality expression: the variable’s full package path, a dot, its
name, then the requested value. As the linker joins separately compiled package parts into a
single binary, it already keeps a table of every name each package exports — this table normally
exists to bind calls between packages together. The -X flag uses this same table for another
purpose: it looks up the name in the table and puts the given string in place of the value the
source declared first. This is why the target name must be written fully and correctly —
if the package path is wrong or the name itself is misspelled, the linker usually finds no match
at all and silently ignores the flag; the default value from the source stays as it was.
// 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"
"strings"
)
// show prints a fact, who states it, and its evidence on a single line.
func show(info, speaker, evidence string) {
fmt.Printf("%-38s %-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("", "linker")
if err != nil {
return ""
}
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module linktrial\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 the output and whether it succeeded.
func runTool(dir string, arg ...string) (string, bool) {
cmd := exec.Command("go", arg...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
output, err := cmd.CombinedOutput()
return string(output), err == nil
}
// run runs the source with the given linker value and returns the single line it printed. If
// apply is false, no -X flag is supplied; if true, the value is applied even if it is empty.
func run(dir string, apply bool, value string) string {
arg := []string{"run", "."}
if apply {
arg = []string{"run", "-ldflags=-X main.version=" + value, "."}
}
output, _ := runTool(dir, arg...)
return strings.TrimRight(output, "\n")
}
// symbolPresent says whether a given symbol name is present in a binary built with the given
// extra linker flag; the binary itself and its path are never printed anywhere.
func symbolPresent(dir, extraFlag, symbolName string) bool {
binaryPath := filepath.Join(dir, "binary")
arg := []string{"build", "-o", binaryPath}
if extraFlag != "" {
arg = append(arg, "-ldflags="+extraFlag)
}
arg = append(arg, ".")
if _, ok := runTool(dir, arg...); !ok {
return false
}
output, _ := runTool(dir, "tool", "nm", binaryPath)
return strings.Contains(output, symbolName)
}
// measurement1.go — a value not written in the source, read three different ways from the same source
package main
import "fmt"
const sourceText = "package main\n\nimport \"fmt\"\n\nvar version = \"development\"\n\nfunc main() {\n\tfmt.Println(version)\n}\n"
func main() {
dir := setupModule(map[string]string{"prog.go": sourceText})
defer func() { _ = dir }()
fmt.Println("-- same source, three different linker inputs --")
show("no value supplied", "tool", run(dir, false, ""))
show("first value supplied", "tool", run(dir, true, "version-a"))
show("second value supplied", "tool", run(dir, true, "version-b"))
}
-- same source, three different linker inputs -- no value supplied tool development first value supplied tool version-a second value supplied tool version-b
BD9. The sourceText constant is character-for-character identical across all three runs;
the only thing that changes is the flag given to the run function. The only value actually
written in the source is "development" — the value shown when no flag is supplied at all, the
one line belonging to the source class. The other two lines are nowhere in the source; the
linker replaces a package-level string variable’s initial value with a string supplied from
outside, during the build, without ever touching the source. All three of these lines are written
to the tool class, because the value gives the same result on the same flag every time — there
is nothing changing from run to run, only which flag is supplied changes.
This mechanism can only work under two conditions at once. The variable must be at package level — a local variable inside a function sits somewhere the linker cannot reach. And the variable’s type must be a string; the linker can only change string values this way, it cannot change an integer or a struct’s field through the same path. These two constraints keep the mechanism deliberately narrow: it exists to place a single piece of text at build time, not a complex data structure.
This narrowness is not a shortcoming, it is a design choice. The linker never parses the source at all, it works only with the binary representation inside compiled object files — a string variable’s binary representation is a memory block whose length and bytes are known in advance, so all the linker has to do is change that block’s contents. The same operation would be far more fragile for an integer or a struct: putting an arbitrary byte in place without knowing the value’s type and size would risk corrupting the rest of the binary. A string type carries no such risk, because Go’s string representation is already kept together with a length field, and the linker can write the new value’s length into that field too.
Limiting Measurement: The Embedded Value Is Not Validated
All three values above were formatted, readable strings. The linker itself never looks at this form at all — it places whatever it is given exactly as it is. The measurement below tests this with two extremes: on one side a string matching no recognizable pattern at all, on the other a completely empty string.
// measurement2.go — limiting measurement: the embedded value is not validated, the symbol table is a separate matter
package main
import "fmt"
const sourceText = "package main\n\nimport \"fmt\"\n\nvar version = \"development\"\n\nfunc main() {\n\tfmt.Println(version)\n}\n"
func main() {
dir := setupModule(map[string]string{"prog.go": sourceText})
defer func() { _ = dir }()
fmt.Println("-- limiting measurement: the embedded value is not validated --")
show("unformatted value supplied", "tool", run(dir, true, "###INVALID-VERSION-999"))
show("empty string supplied", "tool", "["+run(dir, true, "")+"]")
fmt.Println()
fmt.Println("-- second linker option: removing the symbol table --")
show("symbol present in normal build", "tool", fmt.Sprint(symbolPresent(dir, "", "main.version")))
show("symbol present with -s build", "tool", fmt.Sprint(symbolPresent(dir, "-s -w", "main.version")))
show("does -s change behavior", "tool", "output="+run(dir, false, ""))
}
-- limiting measurement: the embedded value is not validated -- unformatted value supplied tool ###INVALID-VERSION-999 empty string supplied tool [] -- second linker option: removing the symbol table -- symbol present in normal build tool true symbol present with -s build tool false does -s change behavior tool output=development
BD10. The first line accepted a string that does not look like a version number at all, with no warning, no rejection; the second line accepted a completely empty string the same way — the brackets are empty because the variable itself was set equal to an empty string. The linker performs no type check and no format check here; all it does is put the given byte sequence in place of the source code’s initial value. This is another face of the previous lesson’s “compiled is not the same as working” limit: if the linker passes, the embedded value is never guaranteed to be meaningful — only that it was placed in the right spot, with the right type.
The only way to close this gap is to validate the embedded value outside the source, in a separate step — the linker itself never takes on this job. In a deployment process this is usually solved by testing, before the linker is ever called, whether the string to be embedded matches a pattern known in advance to the caller — exactly as this lesson measured, the validation comes not from the linker itself but from the discipline of the process calling it. This lesson did not apply that discipline, deliberately: the goal was to see the linker’s own behavior in its bare form, with no surrounding validation layer at all.
BD11. The second measurement looks at a separate linker option: the flag that removes the
symbol table and debug information. In a normal build, the name main.version is present in the
binary’s symbol table — this name lets a debugger or an analysis tool recognize the variable.
With the same flag added, this name is removed from the symbol table, but the program’s own
output — the run call on the last line — does not change at all. This shows that two different
linker options work at two different layers: one changes the program’s data (-X), the other
only removes information about the program (-s) — the two can be used together, and one
never affects the other’s result.
This distinction is not arbitrary: the symbol table and debug information that -s removes are
no part at all of the program’s own logic — extra data added to the binary only so a debugger
can read variable names, function names, and line numbers, but never used by the program itself
at run time. -X, by contrast, writes into exactly the opposite place — inside the program’s own
runtime data. Even when both options are given in the same command, no ordering problem arises
between them, because the symbol table is removed at the end of the build process, once every
value has already settled into place — what gets removed is the names themselves, not the data
those names point to.
Without Leaving a Trace in the Source
What both measurements share is that the layer the linker works in is separate from the source
itself. The source file stayed character-for-character identical across the version-a,
version-b, and ###INVALID-VERSION-999 runs; none of them appears in any version of the source
on disk. This completes the parallel pointed to at the end of the previous lesson: just as target
selection enters the binary leaving no trace at all, an embedded string enters leaving no trace
in the source either. Both are applied by the build process’s own tools, as part of the build’s
input — not the source’s output.
The practical consequence of this is that every binary produced from the same source tree can carry its own “identity”: values like a version string, a build timestamp, a distribution ID can be given separately to each binary, without ever changing the source, only by changing the build call. The cost sits in the same place: the correctness of these values falls outside source review — without a watching, reviewing person or process, a wrong value can silently enter the binary. This difference looks sharper when compared, in the next lesson, against a version number genuinely written in the source: there, the version number sits in the source itself, and a tool only checks it; here, the version-like value never sits in the source at all, and no tool checks it.
The choice between these two extremes usually depends on one question: is the value part of the source’s identity, or is it part of when, in what environment the source was built? A library’s version number is an example of the first — that value moves with the source, it does not change without the source changing, so writing it into the source keeps a more consistent record. A build ID or a fact about the build environment is an example of the second — this value does not come from the source, it comes from that particular build call, and it can be expected to differ on every build; writing it into the source would, in this case, mean polluting the source with information that does not actually belong to it. A linker option meets exactly this second case, without ever touching the source.
Summary
- A string variable declared at package level can be filled with a different value at build time through a linker flag, with the source never changing; this value is never written in the source.
- The same source gave three different outputs across three separate calls; the difference was only in the flag supplied, never in any version of the source file.
- Limiting measurement: the linker never validates the embedded value — an unformatted string and an empty string are both accepted the same way, with no warning produced.
- A separate linker option removing the symbol table does not change the program’s own output; the two options work in two independent layers.
- Target selection (previous lesson) and the embedded value (this lesson) share the same pattern: both are part of the build’s input, not part of the source itself.
Next Step
In this lesson the embedded value was never written in the source, and no tool ever checked it. The next lesson takes up the exact opposite case: a version number is declared openly, in the source itself — but this time a tool checks a promise that number carries, whether it stays compatible with old code. The question is no longer “where does the value come from” but “what does a number written in the source guarantee.”
To keep your progress and take notes, Log in
My notes
Log in to take notes.