原創文章,歡迎轉載,轉載請註明出處,謝謝。
0. 前言
作為一個嚴肅的 Gopher,瞭解彙編是必須的。本彙編系列文章會圍繞基本的 Go 程式介紹彙編的基礎知識。
1. Go 程式到彙編
首先看一個簡單到令人髮指的示例:
package main
func main() {
a := 1
print(a)
}
執行程式,輸出:
# go run ex0.go
1
當使用 go run
執行程式時,程式碼會經過編譯,連結,執行得到輸出,這是自動執行的,沒辦法檢視中間過程。我們可以使用 dlv
檢視這段程式碼在執行時做了什麼。dlv
將程式碼載入到記憶體中交給 CPU 執行,又不喪失對 CPU 的控制。換言之,我們是在底層透過 dlv
對 CPU 進行除錯檢視程式碼的執行過程,這對我們瞭解程式的執行是非常有幫助的。
使用 dlv debug
除錯程式:
# go mod init ex0
go: creating new go.mod: module ex0
go: to add module requirements and sums:
go mod tidy
# dlv debug
Type 'help' for list of commands.
(dlv)
使用 disass
可檢視應用程式的彙編程式碼,這裡的彙編是真實的機器執行的彙編程式碼。彙編是離機器最近的“語言”,翻譯成彙編可以幫助我們知道機器在對我們的程式碼做什麼。
(dlv) disass
TEXT _rt0_amd64_linux(SB) /usr/local/go/src/runtime/rt0_linux_amd64.s
=> rt0_linux_amd64.s:8 0x466d00 e95bc9ffff jmp $_rt0_amd64
從這段彙編程式碼可以看出,進入 main
函式前,機器執行的是 Go runtime 中 rt0_linux_amd64.s
第 8 行的彙編指令。檢視 rt0_linux_amd64.s
:
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
#include "textflag.h"
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
JMP _rt0_amd64(SB)
TEXT _rt0_amd64_linux_lib(SB),NOSPLIT,$0
JMP _rt0_amd64_lib(SB)
第 8 行執行的是 JMP _rt0_amd64(SB)
跳轉指令。
使用 si
命令單步除錯,si
是指令級除錯。執行 si
檢視的是 CPU 執行的下一條指令:
(dlv) si
> _rt0_amd64() /usr/local/go/src/runtime/asm_amd64.s:16 (PC: 0x463660)
Warning: debugging optimized function
TEXT _rt0_amd64(SB) /usr/local/go/src/runtime/asm_amd64.s
=> asm_amd64.s:16 0x463660 488b3c24 mov rdi, qword ptr [rsp]
asm_amd64.s:17 0x463664 488d742408 lea rsi, ptr [rsp+0x8]
asm_amd64.s:18 0x463669 e912000000 jmp $runtime.rt0_go
CPU 執行的是 runtime/asm_amd64.s
中的彙編指令。檢視 runtime/asm_amd64.s
:
// _rt0_amd64 is common startup code for most amd64 systems when using
// internal linking. This is the entry point for the program from the
// kernel for an ordinary -buildmode=exe program. The stack holds the
// number of arguments and the C-style argv.
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)
可以看到,Go runtime 的彙編和機器實際執行的彙編指令有所出入。這裡 Go 的彙編可以理解成在彙編之上又定製的一層彙編,要注意的是機器實際執行的是 Go 彙編翻譯之後的彙編。
1.1 main 函式棧
本文的重點並不是單步除錯 runtime 的彙編指令,我們使用 b
給 main 函式加斷點,使用 c
執行到斷點處,重點看 main 函式中的執行過程:
(dlv) b main.main
Breakpoint 1 set at 0x45feca for main.main() ./ex0.go:3
(dlv) c
> main.main() ./ex0.go:3 (hits goroutine(1):1 total:1) (PC: 0x45feca)
1: package main
2:
=> 3: func main() {
4: a := 1
5: print(a)
6: }
程式執行到 ex0.go 的第三行。disass
檢視彙編指令:
(dlv) disass
TEXT main.main(SB) /root/go/src/foundation/ex0/ex0.go
ex0.go:3 0x45fec0 493b6610 cmp rsp, qword ptr [r14+0x10]
ex0.go:3 0x45fec4 762b jbe 0x45fef1
ex0.go:3 0x45fec6 55 push rbp
ex0.go:3 0x45fec7 4889e5 mov rbp, rsp
=> ex0.go:3 0x45feca* 4883ec10 sub rsp, 0x10
彙編程式碼顯示執行到記憶體地址 0x45feca
處,記憶體地址中儲存的是彙編指令 sub rsp, 0x10
,對應的十六進位制是 4883ec10
,轉換為二進位制機器指令是 1001000100000111110110000010000
。
我們有必要分段介紹執行 sub rsp, 0x10
前 CPU 執行的指令,以方便理解。
首先,cmp rsp, qword ptr [r14+0x10]
指令比較 rsp 暫存器的值和 [r14+0x10] 暫存器中的值,並將比較的結果儲存到標誌暫存器中。
接下來,指令 jbe 0x45fef1
將讀取標誌暫存器的結果,如果比較結果 rsp 小於或等於 [r14+0x10] 則跳轉到記憶體 0x45fef1
。檢視 0x45fef1
中儲存的指令:
ex0.go:3 0x45fef1 e8eacdffff call $runtime.morestack_noctxt
0x45fef1
儲存的是 runtime.morestack_noctxt
函式的呼叫。
機器指令的語義較難理解這幾條指令在幹嘛,翻譯成語義資訊就是,如果當前 main 函式棧的棧空間不足,則呼叫 runtime.morestack_noctxt
申請更多棧空間。
接著,繼續執行指令 push rbp
。在介紹這條指令前,有必要介紹下機器的暫存器,使用 regs
命令檢視機器的暫存器:
(dlv) regs
Rip = 0x000000000045feca
Rsp = 0x000000c00003e758
Rax = 0x000000000045fec0
Rbx = 0x0000000000000000
Rcx = 0x0000000000000000
Rdx = 0x00000000004751a0
Rsi = 0x00000000004c3160
Rdi = 0x0000000000000000
Rbp = 0x000000c00003e758
...
機器有很多種暫存器,我們重點關注的是 Rip
,Rsp
和 Rbp
暫存器。
Rip 暫存器中儲存的是 CPU 當前執行指令的記憶體地址,這裡要注意,程式中的記憶體地址為虛擬地址,不存在段地址和偏移地址。當前 Rip
中儲存的是 0x000000000045feca
,對應執行的機器指令是 => ex0.go:3 0x45feca* 4883ec10 sub rsp, 0x10
。
Rsp
暫存器一般作為函式棧的棧頂,用來儲存函式棧的棧頂地址。Rbp
一般用來儲存程式執行的下一條指令,函式棧在跳轉時需要知道下一條執行的指令在什麼位置(這裡不清楚也沒關係,後續文章會介紹)
回到 push rbp
指令,該指令會將 rbp
暫存器的值壓棧,壓棧是從高地址到低地址,Rsp
暫存器將減小 8 個位元組。然後 mov rbp, rsp
指令將當前 rsp
暫存器的值賦給 rbp
, rbp
將作為函式棧的棧底存在。
根據上述分析,可以畫出當前棧的記憶體空間如下:
繼續單步執行 sub rsp, 0x10
指令,rsp
向下減 0x10
,這是為 main
函式棧開闢棧空間。rsp 值為:
(dlv) regs
Rsp = 0x000000c00003e748
disass
檢視後續執行的彙編指令:
(dlv) disass
Sending output to pager...
TEXT main.main(SB) /root/go/src/foundation/ex0/ex0.go
...
=> ex0.go:4 0x45fece 48c744240801000000 mov qword ptr [rsp+0x8], 0x1
ex0.go:5 0x45fed7 e8e449fdff call $runtime.printlock
ex0.go:5 0x45fedc 488b442408 mov rax, qword ptr [rsp+0x8]
ex0.go:5 0x45fee1 e87a50fdff call $runtime.printint
ex0.go:5 0x45fee6 e8354afdff call $runtime.printunlock
ex0.go:6 0x45feeb 4883c410 add rsp, 0x10
ex0.go:6 0x45feef 5d pop rbp
mov qword ptr [rsp+0x8], 0x1
將 0x1
放到 [rsp+0x8] 記憶體地址中。使用 x
命令可以檢視記憶體地址中的值:
x 0x000000c00003e750
0xc00003e750: 0x01
接著,mov rax, qword ptr [rsp+0x8]
將記憶體地址 [rsp+0x8]:0x000000c00003e750
的值複製到暫存器 rax
中,呼叫 call $runtime.printint
列印暫存器中的值(這裡忽略 call $runtime.printint
和 call $runtime.printunlock
指令)。
在我們執行下一條指令 add rsp, 0x10
前先看下當前記憶體空間使用情況。
main
函式棧中 rbp
指向的是函式棧的棧底,rsp
指向的是函式棧的棧頂,在 [rsp+0x8]
的地址存放著區域性變數 1。
接著,執行 add rsp, 0x10
回收棧空間:
(dlv) si
> main.main() ./ex0.go:6 (PC: 0x45feef)
ex0.go:6 0x45feeb* 4883c410 add rsp, 0x10
=> ex0.go:6 0x45feef 5d pop rbp
(dlv) regs
Rsp = 0x000000c00003e758
要注意,回收只是改變 Rsp
暫存器的值,記憶體中的資料還是存在的,這是棧段,資料並不會被垃圾回收器回收:
x 0x000000c00003e750
0xc00003e750: 0x01
繼續,執行 pop rbp
將原來儲存在棧底處的值放到 rbp
暫存器中:
(dlv) regs
Rip = 0x000000000045feef
Rsp = 0x000000c00003e758
Rbp = 0x000000c00003e758
(dlv) si
> main.main() ./ex0.go:6 (PC: 0x45fef0)
ex0.go:6 0x45feef 5d pop rbp
=> ex0.go:6 0x45fef0 c3 ret
(dlv) regs
Rip = 0x000000000045fef0
Rsp = 0x000000c00003e760
Rbp = 0x000000c00003e7d0
最後執行 ret
指令退出 main
函式。
至此,我們一個簡單的列印區域性變數的程式就分析完了。下一篇,我們繼續看,如何手寫 plan9 彙編。