Understanding the Go Compiler: From Source Code to Execution

Arya M. PathakArya M. Pathak
21 min read

The Go compiler is a critical tool that transforms human-readable Go source code into highly optimized machine code capable of running on various platforms. While the compilation process may seem like a mysterious conversion, it is actually a structured, multi-step pipeline. The process begins with key stages such as lexical analysis, syntax parsing, and type checking, where the raw source code is tokenized, organized into Abstract Syntax Trees (AST), and checked against Go’s strict typing rules. These early steps lay the foundation for the rest of the compilation process by ensuring that the code is well-structured and error-free before proceeding to more advanced transformations. Understanding these stages is essential for developers who want to gain insight into how Go's high-level code is prepared for execution.

Beyond these initial phases, the Go compiler engages in more sophisticated steps, such as generating Intermediate Representation (IR) and converting it to Static Single Assignment (SSA) form. These representations allow the compiler to perform crucial optimizations, including dead code elimination, inlining, and escape analysis, which refine the code for better runtime performance. The final stages of compilation involve linking the optimized code with the runtime and generating machine-specific instructions, ensuring efficient execution on target hardware. In this article, we’ll explore these processes in detail, providing a comprehensive look at how the Go compiler transforms even the simplest Go program into an efficient executable, shedding light on Go’s performance optimization and execution model.

Overview of the Compiler Phases

A compiler is generally composed of the following phases:

  1. Lexical Analysis (Lexer/Scanner): Breaks the source code into tokens.

  2. Syntax Analysis (Parser): Converts the token stream into an Abstract Syntax Tree (AST).

  3. Type Checking: Ensures that the code adheres to Go’s strong typing rules.

  4. Intermediate Representation and Optimization: Converts the AST into an intermediate form and applies optimizations.

  5. Code Generation: Produces the final machine code for the target architecture.


Lexical Analysis: The Lexer

The lexer (also known as a scanner) is the first phase of the compiler. Its job is to read the source code one character at a time and group those characters into tokens. A token represents the smallest meaningful unit in the code—such as keywords (package, func, etc.), identifiers, literals (like numbers or strings), operators (+, -, *), or symbols ({, }, (, )).

The lexer works as follows:

  1. Character-by-character scanning: The lexer reads one character at a time and builds up tokens.

  2. Token generation: When a complete token (like a keyword or identifier) is recognized, it is returned to the parser.

  3. Automatic semicolon insertion: Go has optional semicolons, but from the lexer’s perspective, they are required for parsing. The lexer handles this by automatically inserting semicolons when needed (e.g., after return statements or at the end of a line if the next token is on a new line).

Example:

package main
import "fmt"
func main() {
   fmt.Println("Hello, world")
}

The lexer processes the above code character by character, generating the following tokens:

  • package (keyword)

  • main (identifier)

  • import (keyword)

  • "fmt" (string literal)

  • func (keyword)

  • main (identifier)

  • ( (symbol)

  • ) (symbol)

  • { (symbol)

  • fmt (identifier)

  • . (symbol)

  • Println (identifier)

  • ( (symbol)

  • "Hello, world" (string literal)

  • ) (symbol)

  • } (symbol)

The lexer’s main job is simply to categorize chunks of the source code into these tokens for the next stage.

How the Go Lexer is Implemented

The Go lexer in the compiler is largely a big switch-case statement. Each character or sequence of characters is evaluated to determine which token type it belongs to. For example, consider the handling of semicolons:

switch char {
    case ';':
        return Token{Type: SEMICOLON}
    case '+':
        if nextChar == '=' {
            return Token{Type: ADD_ASSIGN}
        }
        return Token{Type: ADD}
}

The lexer can also look ahead to handle more complex tokens like +=, or ellipses (...).


Syntax Analysis: The Parser

Once the lexer has produced a stream of tokens, the parser takes over. The parser’s job is to turn this token stream into an Abstract Syntax Tree (AST). An AST is a tree-like structure that represents the hierarchical organization of the code.

The parser operates based on predefined grammar rules that dictate how tokens can be combined to form valid programs. The parser is responsible for detecting and reporting any syntax errors.

The Go AST

In Go, each file has its own AST, with the file node acting as the root. This AST contains all the constructs present in the source file, such as package declarations, import statements, function declarations, and more.

The parser works by recursively consuming tokens from the lexer and building up the tree structure. For example, after the lexer returns the package token, the parser expects the next token to be an identifier (the package name). If the parser encounters an unexpected token, it will raise a syntax error.

Example:

For our "Hello, World" example, the resulting AST might look something like this:

  • Root (File node)

    • Package declaration: main

    • Import declaration: fmt

    • Function declaration: main()

      • Body:

        • Expression: fmt.Println("Hello, world")

Each node in the AST contains additional metadata like line numbers, positions, and comments to assist with error reporting and code navigation.

How the Go Parser is Implemented

The Go parser is driven by a set of grammar rules. These rules dictate what kinds of statements and expressions can be formed from the tokens produced by the lexer.

For example, the grammar for an import statement might look like this:

ImportSpec = { Name "." | PackageName } ImportPath ;

This rule says that an import spec can consist of an optional name or dot (.), followed by a string literal that specifies the import path. The parser translates this rule into code that checks for these tokens in sequence and constructs an AST node for the import statement.

Here’s a snippet of how the parser processes function declarations:

func parseFuncDecl() *FuncDecl {
    if nextToken == TOKEN_FUNC {
        funcNode := new(FuncDecl)
        parseFuncHeader(funcNode)
        parseFuncBody(funcNode)
        return funcNode
    }
}

This function identifies the func keyword and processes the function header (name, parameters, etc.) and the body (the block of code inside the function).


Type Checking

Once the AST is built, the compiler moves to the type checking phase. This is where the Go compiler ensures that the program obeys Go’s type system rules.

The Go type checker verifies things like:

  1. Type consistency: Ensuring that variables are used according to their declared types (e.g., you can’t assign a string to an int).

  2. Method calls: Checking that method calls are valid, that receivers exist, and that the types match.

  3. Variable declarations: Ensuring that variables are declared before use and checking for unused variables.

  4. Function signatures: Verifying that function calls have the correct number and types of arguments.

Type checking happens in two passes:

  1. First pass: Checks package-level objects like functions, constants, and types without delving into the bodies of functions.

  2. Second pass: Checks the bodies of functions now that the broader context (package-level information) is known.

Example of Type Errors:

For instance, in the following code:

var x int = "hello"

The type checker will throw an error because "hello" is a string, but x is declared as an int.

How the Go Type Checker Works

Go’s type checker works by walking the AST. Each node is examined to ensure that it adheres to the rules of Go’s type system. The type checker attaches type information to each node in the AST.

For example, when checking a function call, the type checker verifies that the function exists, that the function signature is correct, and that the arguments passed match the expected types.


1. Intermediate Representation (IR) Generation

a. What is Intermediate Representation (IR)?

Intermediate Representation (IR) serves as an abstraction between the high-level Go source code and the low-level machine instructions that are executed by the CPU. The primary purpose of IR is to simplify the syntax and structure of the source code, while retaining the logic and semantics of the program. IR is a much more manageable and standardized format, making it easier for the compiler to apply optimizations and transformations.

Go’s IR is generated after the Go source code is parsed into an Abstract Syntax Tree (AST). The AST captures the structure and relationships of the program, but is still very closely tied to the syntax of Go. The next step in the compiler pipeline is to convert the AST into IR.

In Go, IR is specific to each package, meaning that every package in a Go program will have its own intermediate representation. This separation by package allows the Go compiler to efficiently manage imports, type declarations, and function bodies.

b. From AST to IR

To illustrate the process of IR generation, consider the following simple Go program:

package main

import "fmt"

func main() {
    fmt.Println("Hello, World!")
}

The Go compiler first parses this source code into an AST. At this stage, the structure of the program is preserved, but it still closely resembles Go code. Once the AST is created, the compiler walks through the AST nodes, generating IR.

Example of AST to IR Generation

Here’s a simplified look at the transformation process:

  • Package Declaration: The package declaration (package main) is noted, and the IR generation process begins for the package.

  • Imports: The import "fmt" declaration is converted into an IR node that records the import statement and handles the linking of the fmt package.

  • Function Declaration: The main function is converted into an IR node for the function. This node will hold the function’s body, arguments, return types, and metadata like whether it's an init function or the entry point (main).

The IR generated for the main function might look something like this:

# IR Representation (simplified)
package main
import fmt
func main:
    block0:
        call fmt.Println("Hello, World!")
    return

c. IR Node Types

IR is structured as a series of declarations and statements, with each node representing a specific action or entity in the program. Some common IR nodes include:

  • Constant Declaration: This node represents constant values. If a constant is declared, the IR node will store its type and value.

  • Variable Declaration: Similarly, this node represents variables, storing their type and scope. The compiler will infer the type of a variable if it’s not explicitly stated.

  • Function Declaration: Every time a function is declared in the source code, a corresponding IR node is created. If the function is init (Go’s special initializer function), it will also be tagged with metadata for special handling.

Once IR generation is complete, the Go compiler now has a package-level representation that abstracts away syntactic details and captures the essential logic of the program.

d. IR Example: Dealing with Constants

Here’s a slightly more complex example involving a constant:

package main

const pi = 3.14

func main() {
    fmt.Println(pi)
}

In this case, the IR will capture the constant declaration (pi = 3.14) and the usage of pi inside the main function. The IR might look like this:

# IR for main
package main
const pi = 3.14
func main:
    block0:
        call fmt.Println(pi)
    return

In this simplified view of IR, you can see that the constant pi is represented by its name and value. The main function calls fmt.Println, passing pi as an argument.


2. IR Passes and Optimizations

Once the IR is generated, the Go compiler performs several optimization passes over the IR to eliminate unnecessary code and make the program more efficient. These optimizations prepare the IR for later stages of the compiler pipeline, including SSA generation.

a. Dead Code Elimination (DCE)

One of the key optimization passes is Dead Code Elimination (DCE). Dead code refers to code that is unreachable or uncalled during the execution of the program. This could happen due to unused functions, conditional statements that are always false, or unreachable code after a return statement.

For example:

func main() {
    return
    fmt.Println("This will never print")
}

In this case, the fmt.Println call is unreachable because the return statement prevents it from being executed. During the dead code elimination pass, the compiler removes this unreachable block, resulting in more efficient IR.

b. Function Inlining

Another key optimization is function inlining. This optimization replaces function calls with the body of the called function if the function is small or simple enough. Function inlining reduces the overhead of calling small functions, especially in performance-critical sections of code.

For example:

func add(a, b int) int {
    return a + b
}

func main() {
    x := add(1, 2)
}

If the add function is deemed simple enough, the inlining optimization will replace the call to add(1, 2) with its body. The resulting IR will look like this:

# Inlined function example
func main:
    block0:
        x = 1 + 2
    return

c. Virtual Function Elimination

Go's support for interfaces means that functions can be dynamically dispatched at runtime when methods are called on interface types. However, if the compiler can determine that an interface method is only ever implemented by one concrete type, it can replace the dynamic method call with a static one, reducing runtime overhead.

For example:

type Greeter interface {
    Greet()
}

type English struct{}

func (e English) Greet() {
    fmt.Println("Hello")
}

func main() {
    var g Greeter = English{}
    g.Greet()
}

In this code, the method Greet() is called on the interface Greeter. If the compiler determines that English is the only type used for Greeter, it will optimize the Greet() method call to directly call English.Greet().


3. Escape Analysis

Escape analysis is a critical optimization that the Go compiler uses to decide whether variables should be allocated on the stack or the heap. This decision is important for performance because stack allocations are much cheaper and do not require garbage collection.

a. Basic Example of Escape Analysis

Consider the following Go code:

func createPointer() *int {
    x := 42
    return &x
}

In this function, the variable x is declared locally, but since its address is returned, it escapes the function’s stack frame. As a result, x must be allocated on the heap, where its lifetime can extend beyond the function call.

b. Escape Analysis in Action

Escape analysis is performed during IR passes, and the compiler will generate IR nodes that instruct whether a variable should be allocated on the heap or stack. This analysis is particularly important in determining the performance characteristics of your program, especially in concurrent code where many goroutines may be running at the same time.


4. Static Single Assignment (SSA) Generation

After the IR has undergone several optimization passes, the next step is to convert it into Static Single Assignment (SSA) form. SSA is an intermediate representation that simplifies many kinds of optimizations by ensuring that each variable is assigned exactly once.

a. What is SSA?

SSA enforces two key rules:

  1. Each variable can only be assigned once. If a variable’s value changes, a new version of that variable is created.

  2. Each variable must be defined before it is used. This ensures that there is no ambiguity in the flow of data.

For example:

x := 1
x = x + 1

In SSA, this would be transformed into:

x1 = 1
x2 = x1 + 1

In SSA form, every new value of x gets a unique version (x1, x2, etc.), which simplifies the data flow and helps the compiler perform optimizations such as constant propagation and value numbering.

b. Blocks and Values in SSA

In Go’s SSA, the code is structured into blocks and values:

  • Blocks: Each block represents a sequence of instructions (values) that execute sequentially. A block may have one or more successor blocks, corresponding to different control flow paths (e.g., branches in an if statement).

  • Values: Each value corresponds to an operation, such as an addition or a function call. Every value is associated with a block and has a unique identifier, an operator (e.g., add, call), a type, and one or more operands.

Example of SSA for a Simple Function
func add(a, b int) int {
    return a + b
}

The SSA representation for this function might look like this:

0:
    v1 = param a
    v2 = param b
    v3 = add v1, v2
    return v3

In this SSA form, the addition (v3 = add v1, v2) happens in block0, and the result (v3) is returned.


5. SSA Passes and Optimizations

Once SSA is generated, the Go compiler performs a series of SSA passes to optimize the code further. These passes are designed to improve performance by eliminating unnecessary instructions, reordering expressions, and lowering high-level operations to architecture-specific machine code.

a. Common Subexpression Elimination (CSE)

Common subexpression elimination is an optimization that identifies expressions that are computed multiple times with the same operands and replaces them with a single computation. This can save both time and space.

For example:

x = a + b
y = a + b

In SSA, this would be optimized as:

x1 = a + b
y1 = x1

The addition (a + b) is only computed once, and the result is reused for both x and y.

b. Dead Code Elimination in SSA

Just like in IR, dead code elimination in SSA removes blocks or values that are never executed. For example, after performing inlining and constant folding, the compiler might discover blocks that are no longer reachable, and it will remove them to optimize the code.

c. Lowering SSA

Lowering is the process of transforming architecture-independent SSA code into a form that is dependent on the specific CPU architecture being targeted (e.g., x86-64, ARM). This involves converting high-level SSA operations into low-level machine instructions that correspond to the target architecture.

For example, an SSA addition operation (add) may be lowered to an ADD instruction for x86-64 or an ADDW instruction for ARM. Lowering is crucial because it allows the Go compiler to take advantage of architecture-specific optimizations and features.

d. Control Flow Optimizations

Go’s SSA representation allows for optimizations related to control flow. For example, if an if statement's condition is determined to be constant, the compiler can eliminate one of the branches entirely, simplifying the control flow.

Consider the following code:

if true {
    x = 1
} else {
    x = 2
}

Since the condition is always true, the SSA pass can remove the else block entirely.


Machine Code Generation

The first step after parsing and type-checking in the Go compiler is the generation of an intermediate representation (IR) called SSA (Static Single Assignment). SSA is a representation of code where each variable is assigned exactly once, making it easier for the compiler to apply optimizations. SSA in Go consists of two key steps:

  1. Lowering SSA to Architecture-Specific Assembly: Once the SSA form has been optimized, the compiler translates it to machine code. The machine code generation process is architecture-specific and involves the compiler converting high-level Go constructs into low-level assembly instructions that a specific CPU can understand.

  2. Examples of SSA and Assembly Parallelism: If we analyze the SSA and the corresponding assembly code side-by-side, there’s a clear parallelism. For example, an SSA instruction to add two numbers might directly translate into an architecture-specific assembly instruction like ADD for x86, or ADDI for ARM.

  3. Instruction Selection: The Go compiler checks SSA operations to decide on specific machine instructions. For instance, depending on whether a function call is static or tail-recursive, different assembly code is generated. A function call could result in a CALL instruction, and conditional checks will lead to different instructions depending on the processor family (e.g., x86 vs ARM).

  4. Architecture-Specific Handling: One interesting aspect of Go's machine code generation is how it handles specific architecture constraints. Depending on whether an architecture uses registers or memory for passing arguments, the Go compiler adjusts the generated code accordingly.

2. Linking: Merging Code with the Runtime

After generating architecture-specific assembly code, the next critical step is linking. The linker’s job is to take the compiled machine code and integrate it with the Go runtime. Without the runtime, the program would not have access to essential features like memory management, goroutines, and garbage collection.

Stage 1: The Go Runtime

The Go runtime is a vital component of any Go program. It includes code for:

  1. Memory Management: The Go runtime manages memory allocation and garbage collection. The garbage collector (GC) periodically reclaims memory that is no longer in use by the program, preventing memory leaks.

  2. Goroutines and Scheduling: Go uses a lightweight concurrency model based on goroutines. The runtime’s scheduler manages the execution of goroutines, distributing them across system threads and ensuring efficient use of CPU resources.

  3. Map, Slice, and Channel Implementations: Built-in data structures like maps, slices, and channels are handled by the runtime, which provides optimized implementations and ensures that operations like resizing and synchronization work correctly.

Stage 2: Linking the Binary

Once the machine code is generated, the linker combines it with the Go runtime and other external libraries. This produces a self-contained executable that includes all the necessary components for execution.

The linker performs several key tasks:

  1. Resolution of External References: During the compilation stage, functions and data may reference external symbols (e.g., from the runtime or standard library). The linker resolves these references, ensuring that all calls and data accesses point to the correct addresses in the final binary.

  2. Combining Object Files: If a Go program is spread across multiple source files or packages, the linker combines their corresponding object files into a single executable.

  3. Setting Up Entry Points: The entry point for a Go program is typically the main() function. However, the linker ensures that before main() is called, the Go runtime is initialized, setting up key components like memory management, the garbage collector, and the goroutine scheduler.

    For example, before main() runs, the Go runtime might perform the following steps:

    • Initialize heap and memory management.

    • Start the garbage collector.

    • Set up the goroutine scheduler.

    • Then, and only then, does it call main().

3. Execution and System Calls: Interfacing with the OS

Once the binary is fully linked, it is ready to be executed. However, Go programs don’t interact directly with the hardware. Instead, they rely on the operating system to mediate hardware access through system calls (syscalls).

Stage 1: System Calls in Go

A syscall is a mechanism by which a user-space program requests services from the operating system kernel. These services include tasks like reading from a file, writing to the screen, or allocating memory.

In Go, system calls are abstracted behind functions provided by the runtime and the standard library. For example:

  • File IO: When using os.Open() or os.Write(), Go ultimately calls the relevant syscalls (open and write, respectively) to interact with the filesystem.

  • Network IO: Networking operations in Go, such as opening a socket or sending data, are handled by syscalls like socket() and send().

Stage 2: Example - Printing "Hello, World!"

Consider the following simple Go program:

package main

import "fmt"

func main() {
    fmt.Println("Hello, World!")
}

At first glance, this program seems straightforward—it prints a string to the console. However, under the hood, several steps are involved:

  1. Go Runtime: When the program starts, the Go runtime initializes, setting up memory management, garbage collection, and goroutines.

  2. System Call - write: The actual act of printing the string to the console is done through a syscall. The Go runtime writes the string to the standard output file descriptor (fd = 1) using the write syscall.

    Using a tool like strace, we can observe the system call used by this Go program:

     $ strace ./hello
     ...
     write(1, "Hello, World!\n", 14) = 14
     ...
    

    This shows that the Go program is invoking the write syscall with file descriptor 1 (standard output), writing the string "Hello, World!\n".

Stage 3: How Syscalls Are Handled

The Go runtime makes extensive use of syscalls to interact with the operating system. Each syscall involves switching from user mode to kernel mode, where the operating system takes over control, performs the requested operation (such as writing to the console or allocating memory), and then returns control to the Go program.

Some common syscalls used by Go programs include:

  • write: Writing data to a file or standard output.

  • read: Reading data from a file or standard input.

  • mmap: Memory mapping, often used for efficient file access.

  • open: Opening files for reading or writing.

  • fork and exec: Creating and managing new processes (used for os/exec package functions).

4. Putting It All Together: Compilation to Execution Flow

Let’s recap the full compilation-to-execution flow of a Go program:

  1. Lexing and Parsing: The source code is tokenized and transformed into an Abstract Syntax Tree (AST).

  2. Type Checking and SSA Generation: The AST is type-checked, and then converted into SSA form. SSA is optimized through techniques like inlining, escape analysis, and dead code elimination.

  3. Lowering SSA to Assembly: The optimized SSA is lowered to machine-specific assembly code. This process involves architecture-specific instruction selection and further optimizations.

  4. Linking: The generated assembly code is linked with the Go runtime, ensuring that memory management, garbage collection, and other runtime services are available to the program.

  5. Execution and Syscalls: When the binary is executed, the Go runtime manages interaction with the operating system via syscalls. For example, printing to the screen involves making a write syscall to standard output.

  6. Kernel Mediation: The OS kernel mediates hardware interactions, converting high-level operations like writing to the screen into the necessary hardware instructions.

Conclusion

The Go compiler is an elegantly crafted system that transforms human-readable Go code into efficient machine instructions. Its process begins with crucial stages like lexical analysis, syntax parsing, and type checking, ensuring that the code is well-structured and adheres to Go’s strict type rules. These foundational phases are essential for converting source code into a form the computer can understand. By gaining a deeper understanding of how the lexer, parser, and type checker work, developers can improve their coding practices and potentially contribute to Go’s compiler development.

As the compilation process progresses, the use of Intermediate Representation (IR) and Static Single Assignment (SSA) becomes central to optimization. These forms allow the compiler to perform advanced optimizations such as dead code elimination, function inlining, and escape analysis, ultimately generating highly efficient machine code tailored to specific hardware architectures. By appreciating the Go compiler’s intricate stages—from SSA generation to machine code production and system-level interactions—developers can write more performance-aware code and gain valuable insights into Go’s powerful blend of simplicity, performance, and safety. For those looking to explore further, Go’s well-documented source code offers a window into the compiler’s internal workings, from its optimization passes to its runtime initialization.

121
Subscribe to my newsletter

Read articles from Arya M. Pathak directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Arya M. Pathak
Arya M. Pathak

Backend Dev | VIT Pune '26 | 🐈Cat Connoisseur /⁠ᐠ⁠。⁠ꞈ⁠。⁠ᐟ⁠\