Runtime Architecture of Managed Languages and Analysis of Go Binary AMD64 Assembly

What Is a Managed Language?
A managed language is a language that, unlike unmanaged languages (in which the programmer's code is executed with little deviation from the written logic), executes GC, runtime optimizations, green threads, concurrency handling, and similar mechanisms within the runtime, so that the user does not need to perform dangerous low-level management. Languages of this kind have the advantage that developers can focus on business logic and immerse themselves in development; on the other hand, the programmer's intuition and the actual behavior of the program may diverge, so fine-grained runtime tuning is sometimes required. First, we will examine the Go language, which is the most faithful to a minimalist philosophy among managed languages and whose assembly is straightforward.
Binary Structure of Go Language
| .text | .data | .gopclntab, .typelink, etc. |
|---|---|---|
| Machine code to be run | Data to be stored | Language runtime sections |
Because the Go language does not translate user input into machine code on a strict 1:1 basis, the logic in the .text section is closely related to the language runtime sections. In addition, functions such as runtime.printnl() that the user did not write explicitly are added to the assembly of the .text section. Through this automatic code insertion, the Go language helps developers be freed from manual management.
Viewing Only the main Function in Go
First, let us write a simple example source file main.go and examine main on an AMD64 machine.
1package main
2
3func sayHello(msg string) {
4 println(msg)
5}
6
7func main() {
8 sayHello("Hello World")
9}
10
Then build it as follows.
1go build main.go
2
Go supports the go tool for convenient low-level debugging. To view only the assembly of the main function in the main package, enter the following command.
1go tool objdump -s "main\.main" ./main
2
Assembly
1TEXT main.main(SB) /home/yjlee/compare-assembly/go/main.go
2 main.go:7 0x468f60 493b6610 CMPQ SP, 0x10(R14)
3 main.go:7 0x468f64 762f JBE 0x468f95
4 main.go:7 0x468f66 55 PUSHQ BP
5 main.go:7 0x468f67 4889e5 MOVQ SP, BP
6 main.go:7 0x468f6a 4883ec10 SUBQ $0x10, SP
7 main.go:8 0x468f6e 90 NOPL
8 main.go:4 0x468f6f e8cca3fcff CALL runtime.printlock(SB)
9 main.go:4 0x468f74 488d05da290100 LEAQ 0x129da(IP), AX
10 main.go:4 0x468f7b bb0b000000 MOVL $0xb, BX
11 main.go:4 0x468f80 e83bacfcff CALL runtime.printstring(SB)
12 main.go:4 0x468f85 e8f6a5fcff CALL runtime.printnl(SB)
13 main.go:4 0x468f8a e811a4fcff CALL runtime.printunlock(SB)
14 main.go:9 0x468f8f 4883c410 ADDQ $0x10, SP
15 main.go:9 0x468f93 5d POPQ BP
16 main.go:9 0x468f94 c3 RET
17 main.go:7 0x468f95 e8e6afffff CALL runtime.morestack_noctxt.abi0(SB)
18 main.go:7 0x468f9a ebc4 JMP main.main(SB)
19
- The CMPQ instruction compares the stack guard value inside the goroutine control block register (R14) with the current stack pointer (SP) to check whether there is enough space for the current goroutine stack frame. If the space is insufficient, it jumps (JBE) to address 0x468f95, the entry point for stack expansion.
- PUSHQ BP pushes the previous base pointer onto the stack to save it.
- The current stack pointer (SP) is copied into the base pointer (BP) register, fixing the stack reference point at the start of the function.
- Afterward, 16 bytes of local variable stack space are allocated (SUBQ $0x10, SP), and NOPL is used to fill with a no-op instruction to perform CPU cache alignment.
- To synchronize the internal string standard output in the Go runtime, runtime.printlock(SB) is called to acquire the lock.
- The LEAQ instruction stores the starting address of the constant string ("Hello World") into AX, the register used as the first parameter according to the Go ABI specification, among the general-purpose registers.
- Next, the value representing the string length is stored in BX, the second parameter register (MOVL $0xb, BX, that is, 11 in decimal).
- runtime.printstring(SB) is called to print to the console based on the AX (data address) and BX (length) information passed in.
- runtime.printnl(SB) is called to handle the line break.
- Since output is complete, the lock is released through runtime.printunlock(SB).
- ADDQ $0x10, SP restores the 16-byte stack memory that was allocated.
- POPQ BP restores the previous base pointer.
- RET returns control to the point from which the function was called.
- If the initial stack check found insufficient space, runtime.morestack_noctxt.abi0(SB) at address 0x468f95 is called to dynamically expand the stack runtime, in keeping with managed language practice.
- Once stack expansion is complete, control returns (JMP) to the entry point of main.main(SB). As shown, the assembly of the business logic is quite clear, with only a thin layer of runtime management added.
When Optimization Is Absent
The above form is the result of the Go compiler automatically inlining and optimizing the two separate functions. However, for the purposes of study, we will prevent sayHello from being inlined in this case. To do this, compile the source with the following flag.
1go build -gcflags="-l" main.go
2
When the output is printed in the shell, duplicated assembly code can be found.
1yjlee@elegant:~/compare-assembly/go$ go build -gcflags="-l" main.go
2
3go tool objdump -s "main\.sayHello" ./main
4TEXT main.sayHello(SB) /home/yjlee/compare-assembly/go/main.go
5 main.go:3 0x468f60 493b6610 CMPQ SP, 0x10(R14)
6 main.go:3 0x468f64 7636 JBE 0x468f9c
7 main.go:3 0x468f66 55 PUSHQ BP
8 main.go:3 0x468f67 4889e5 MOVQ SP, BP
9 main.go:3 0x468f6a 4883ec10 SUBQ $0x10, SP
10 main.go:5 0x468f6e 4889442420 MOVQ AX, 0x20(SP)
11 main.go:5 0x468f73 48895c2428 MOVQ BX, 0x28(SP)
12 main.go:4 0x468f78 e8c3a3fcff CALL runtime.printlock(SB)
13 main.go:4 0x468f7d 488b442420 MOVQ 0x20(SP), AX
14 main.go:4 0x468f82 488b5c2428 MOVQ 0x28(SP), BX
15 main.go:4 0x468f87 e834acfcff CALL runtime.printstring(SB)
16 main.go:4 0x468f8c e8efa5fcff CALL runtime.printnl(SB)
17 main.go:4 0x468f91 e80aa4fcff CALL runtime.printunlock(SB)
18 main.go:5 0x468f96 4883c410 ADDQ $0x10, SP
19 main.go:5 0x468f9a 5d POPQ BP
20 main.go:5 0x468f9b c3 RET
21 main.go:3 0x468f9c 4889442408 MOVQ AX, 0x8(SP)
22 main.go:3 0x468fa1 48895c2410 MOVQ BX, 0x10(SP)
23 main.go:3 0x468fa6 e8d5afffff CALL runtime.morestack_noctxt.abi0(SB)
24 main.go:3 0x468fab 488b442408 MOVQ 0x8(SP), AX
25 main.go:3 0x468fb0 488b5c2410 MOVQ 0x10(SP), BX
26 main.go:3 0x468fb5 eba9 JMP main.sayHello(SB)
27
When inlining is disabled, MOVQ instructions are inserted that reload the values into stack pointer offsets such as 0x20(SP) to preserve the parameters (AX, BX) in accordance with the function call convention. In other words, it has been confirmed that the target of compiler optimization is these unnecessary memory movement operations and call overhead.
Next Time
Next time, we will cover the if and switch statements in Go. If time permits in the future, we also plan to analyze the Go runtime sections.