Go plan9 彙編: 打通應用到底層的任督二脈

云计算工作坊發表於2024-08-31

原創文章,歡迎轉載,轉載請註明出處,謝謝。


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
	...

機器有很多種暫存器,我們重點關注的是 RipRspRbp 暫存器。

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 將作為函式棧的棧底存在。

根據上述分析,可以畫出當前棧的記憶體空間如下:

image

繼續單步執行 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], 0x10x1 放到 [rsp+0x8] 記憶體地址中。使用 x 命令可以檢視記憶體地址中的值:

x 0x000000c00003e750
0xc00003e750:   0x01

接著,mov rax, qword ptr [rsp+0x8] 將記憶體地址 [rsp+0x8]:0x000000c00003e750 的值複製到暫存器 rax 中,呼叫 call $runtime.printint 列印暫存器中的值(這裡忽略 call $runtime.printintcall $runtime.printunlock 指令)。

在我們執行下一條指令 add rsp, 0x10 前先看下當前記憶體空間使用情況。

image

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 彙編。


相關文章