Featured image of post How to analyze Go code in assembly

How to analyze Go code in assembly

 

Today, I will introduce some commonly used commands and tools for viewing Go assembly code and debugging Go programs. These tools can be used in regular situations or when engaging with colleagues or online discussions, allowing you to have an upper hand in critical moments.

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

For example, if a colleague claims that the first piece of code is more efficient than the second one:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package main

type Student struct {
	Class int
}

func main() {
	var a = &Student{1}
	println(a)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package main

type Student struct {
	Class int
}
func main() {
	var a = Student{1}
	var b = &a
	println(b)
}

and they explained it in such a way that you couldn’t win the argument. What should you do? Just use a single command to generate the assembly code and expose their argument, giving them a reality check.

Generating Assembly Code with go tool

In fact, it’s quite simple. There are two commands that can achieve this:

1
go tool compile -S main.go

or:

1
go build main.go && go tool objdump ./main

The first one is for compilation, which means compiling the source code into an .o object file and outputting the corresponding assembly code.

The second one is for disassembling, which means decompiling the executable file into assembly code. Therefore, you need to compile the code into an executable file using the go build command first.

Although these two commands are not identical, they both reveal that the assembly code generated for the two example code snippets is the same. Your colleague’s “claims” falls apart, and you’ve got them cornered.

Locating the Runtime Source Code

Go is a language with a runtime. What is the runtime? It’s essentially a set of auxiliary programs that the user didn’t write. The runtime writes code for us, such as the Go scheduler.

All we need to know is that we can create goroutines using the go keyword, and then we can pile up our business logic. As for how goroutines are scheduled, we don’t need to worry about it at all because that’s the job of the runtime scheduler.

But how can we correlate our code with the code inside the runtime?

The methods mentioned earlier can achieve this by adding a grep command.

For example, if I want to know which function in the runtime corresponds to the go keyword, I can write a test code snippet:

1
2
3
4
5
6
7
package main

func main() {
	go func() {
		println(1 + 2)
	}()
}

Since the line go func() { }() is on line 4, we can add a condition when using grep:

1
2
3
4
5
go tool compile -S main.go | grep "main.go:4"

// or

go build main.go && go tool objdump ./main | grep "main.go:4"

Pasted image 20240116112336

By analyzing the code, we can immediately see that the line go func(){} corresponds to the newproc() function. By further studying the newproc() function, we can gain a better understanding of how goroutines are created.

Debugging Code with dlv

Some may ask, “Are there any other methods or tools available for debugging Go programs and interacting with them?” The answer is yes! That’s where the dlv debugging tool comes in. It currently provides the best support for debugging Go programs.

Previously, I hadn’t really explored it in-depth and only knew some very basic commands. However, this time I have learned a few advanced commands, which have significantly enhanced my understanding of Go.

Let’s demonstrate how to use dlv with a specific task.

We know that appending elements to a nil slice will not cause any problems. However, if we try to insert new elements into a nil map, it will immediately panic. Why does this happen and where exactly does the panic occur?

First, let’s write a sample program that triggers a panic in a map:

1
2
3
4
5
6
package main

func main() {
	var m map[int]int
	m[1] = 1
}

Next, compile the program and generate an executable file using the go build command:

1
go build main.go

Then, enter the debugging mode using dlv:

1
dlv exec ./main

To set a breakpoint, we can use the b command in three different ways:

  1. b + address
  2. b + line number
  3. b + function name

Let’s set a breakpoint at the line where the map assignment occurs, which is line 5. We’ll add a breakpoint at that line:

1
2
3
(dlv) b main.go:5
Breakpoint 1 set at 0x104203070 for main.main() ./main.go:5
(dlv)

Use the c command to directly run until the breakpoint is reached. Then, execute the disass command to see the assembly instructions:
Pasted image 20240116165330

Next, use the si command to execute a single instruction. Repeat the si command to execute until the mapassign_fast64 function is reached:
Pasted image 20240116165429
Pasted image 20240116170428

Then, let’s set a breakpoint in the map_fast64.go file:

1
b /opt/homebrew/Cellar/go/1.21.6/libexec/src/runtime/map_fast64.go:93

Now, by using the s command, we can step into the branch where h is checked for nil and then the panic function is executed:
Pasted image 20240116170619
Pasted image 20240116170945

At this point, we have found the code that triggers a panic when assigning to a nil map. From here, we can follow the graph and locate the corresponding position in the runtime source code for further exploration.

In addition, we can use the bt command to view the call stack:
Pasted image 20240116171018

Using the frame 1 command, we can jump to the corresponding position. In this case, 1 corresponds to the main.go:5 line where we set the breakpoint. Isn’t it fascinating?

In the graph above, we can also clearly see that the user goroutine is called all the way by the goexit function. When the user goroutine completes, it goes back to the goexit function to do some finalization work. However, that’s beyond the scope of our discussion.

Additionally, dlv can also help us with the second part, “Locating the Runtime Source Code.”

Summary

Today, I provided systematic methods for viewing runtime source code or assembly code corresponding to user code using commands and tools, which is very practical. To summarize:

  1. go tool compile
  2. go tool objdump
  3. dlv

In the future, we will continue analyzing Go source code using these tools, such as for maps and slices. With these tools, our learning process will be more efficient.

true
Last updated on Jun 29, 2024 20:47 CST
Built with Hugo
Theme Stack designed by Jimmy