Go 語言彙編入門 —— 從輸出 HelloWorld 提及

在 JVM 中,字節碼能夠幫咱們搞清楚不少編譯執行的細節, 爲了搞清楚 go 語言底層的語法糖和原理,須要對底層的彙編知識有深刻的瞭解。彙編其實沒有想象中那麼複雜,其實原理上來講跟 Java 字節碼差很少,只是資料不多,由於更接近系統底層,閱讀的難度相對而言更大一些。linux

爲何要學 Go 語言彙編

首先是要破除迷信,同一個問題網上的答案衆說紛紜,好比究竟是傳值仍是傳引用爭論不休,不如靜下心看一下彙編來的踏實。git

下面寫的這些東西不必定都對,可是但願能與你分享一些方法和思路,授之以漁。學習的目的不是掌握這個知識,而是掌握學習知識的方法,觸類旁通,舉一反三,無論學什麼都有本身的一套方法支撐,快速如何,快速解決問題,長遠來看知識自己是沒什麼太大做用的。github

學習 Go 語言彙編不是爲了之後用匯編來作開發,只是能夠用經過閱讀彙編來深入的理解 Go 語言背後的實現細節,真正的精通這門語言,在使用的過程當中能夠更加安心。編程

這篇文章將會首先介紹在 Linux 平臺上用匯編輸出 "Hello, World!",經過這個例子順帶介紹彙編的一些基本的概念。爲後面咱們介紹 Go 語言 Plan9 彙編打下基礎。緩存

用 C 語言寫一個 Hello World 輸出

以前看了很多的彙編的書,有一個感受是,咋沒有跟其它編程書籍同樣,介紹如何輸出 "Hello, World!" 呢?看得多之後就慢慢知道了,用匯編在控制檯輸出 "Hello, World!" 沒有那麼簡單,不是三兩行簡單調用一個函數就完了。bash

爲了搞清楚如何在終端中輸出字符串,咱們先來寫一段 C 語言的實現:app

#include <stdio.h>

int main() {
    char *str = "Hello, World!\n";
    printf("%s", str);
}
複製代碼

更接近系統調用層的寫法是:函數

#include <unistd.h>

int main() {
    int stdout_fd = 1;
    char* str = "Hello, World!\n";
    int length =  14;
    write(stdout_fd, str, length);
}
複製代碼

Unix 的設計哲學,一切皆文件,一個程序運行之後都至少包含三個文件描述符(file descriptor,簡稱 fd):學習

  • 標準輸入 stdin(0)
  • 標準輸出 stdout(1)
  • 錯誤輸出 stderr(2)

在終端執行程序輸出字符串,實際上就是往標準輸出 stdout 文件描述符寫數據,stdout 的 fd 值等於 1。ui

write 是一個系統調用,把數據寫入到文件,它的函數簽名以下:

ssize_t	 write(int fd, void * buffer, size_t count)
複製代碼

第一個參數 fd 表示要寫入的文件描述符,第二個參數 buffer 表示要寫入文件中數據的內存地址,第三個參數表示從 buffer 寫入文件的數據字節數。所以在標準輸出中輸出"Hello, World!\n"其實是調用 write 系統調用,往 fd 爲 1 的文件描述符寫入 14 個字節的字符串。

編譯並執行上面的 C 代碼,就能夠看到輸出了 "Hello, World!" 字符串

gcc main.c -o main
./main 

Hello, World!
複製代碼

CPU、內存與寄存器

彙編主要是跟 CPU 和內存打交道,CPU 自己只負責運算,不負責存儲,數據存儲通常都是放在內存中,咱們知道 CPU 的運算速度遠高於內存的讀寫速度,爲了 CPU 不被內存讀寫拖後腿,CPU 內部引入一級緩存、二級緩存和寄存器的概念,這些資源都很是寶貴,至今都記得有一位老師說過:「二級緩存貴如黃金」。寄存器能夠認爲是在 CPU 內能夠存儲很是少許數據的超高速的存儲單元。由於寄存器個數有限且很是重要,每一個寄存器都有本身的名字,最經常使用的有下面這些,這些先混個眼熟,在後續的文章中再詳細介紹。

%EAX %EBX %ECX %EDX %EDI %ESI %EBP %ESP
複製代碼

系統調用(System Call):內核和應用程序之間的契約

下面咱們來介紹系統調用概念,不少人會想,這還不簡單,我一天能夠寫幾百個系統調用。

內核對外暴露的接口被稱爲系統調用,應用程序能夠調用對應的接口請求內核去完成某些動做,咱們常見的建立新進程、IO 讀寫等都屬於系統調用。

須要注意一下這些知識:

  • 系統調用將處理器從「用戶態」切換到「內核態」
  • 應用程序都是按「名字」來執行系統調用,好比 exit、write,底層上每一個系統調用都對應一個數字,好比 exit 對應 1,write 對應 4,這些數字編號須要被存儲到寄存器 %eax
  • 在調用系統調用時,參數值須要放置到規定好的寄存器中
  • int 0x80 指令用來觸發處理器從用戶態切換到內核態,int 是 interrupt(中斷)的縮寫,不是整數的那個 int。內核收到 0x80 的中斷請求之後,就會並根據前面準備好的寄存器的內容調用相應的系統調用。

執行一個 write 調用的流程以下圖所示:

go assembly.001

彙編寫 Hello World

有了上面的基礎,再來看彙編的代碼,但願不要在這裏就勸退了大部分同窗。文件名是 helloworld.s,下面是彙編的代碼

.section .data

msg:
    .ascii "Hello, World!\n"

.section .text
.globl _start

_start:
    # write 的第 3個參數 count: 14
    movl $14,  %edx
    # write 的第 2 個參數 buffer: "Hello, World!\n"
    movl $msg, %ecx
    # write 的第 1 個參數 fd: 1
    movl $1,   %ebx
    # write 系統調用自己的數字標識:4
    movl $4,   %eax
    # 執行系統調用: write(fd, buffer, count)
    int $0x80

    # status: 0
    movl $0,   %ebx
    # 函數: exit
    movl $1,   %eax
    # system call: exit(status)
    int $0x80

複製代碼

在彙編中,任何以點(.)開頭的都不會被直接翻譯爲機器指令,.section 將彙編代碼劃分爲多個段,.section .data是數據段的開始,數據段中存儲後面程序須要用到的數據,至關於一個全局變量。在數據段中,咱們定義了一個 msg,ascii 編碼表示的內容是 "Hello, World!\n",

接下來的 .section .text 表示是文本段的開始,文本段是存放程序指令的地方。

接下來的指令是 .globl _start,這裏並無拼錯,不是 global,_start 是一個標籤。接下來是真正的彙編指令部分了。

前面介紹過,執行 write 系統調用時,%eax寄存器存儲 write 的系統調用號 4,%ebx存儲標準輸出的 fd,%ecx存儲着輸出buffer 的地址。%edx存儲字節數。因此看到 _start便籤後有四個 movl 指令,movl 指令的格式是:

movl src dst
複製代碼

好比movl $4, %eax指令是講常量 4 存儲到寄存器 %eax 中,數字 4 前面的 $ 表示「當即尋址」,彙編的其它尋址方式後面的文章還會詳細介紹,這裏先不展開,只須要知道當即尋址是自己就包含要訪問的數據,好比要把數據初始化爲 4,不用去哪一個地址去讀 4,在指令中直接給出數字 4。

接下來指令是 int $0x80,前面介紹過,這是一條中斷觸發指令,把執行流程交給內核繼續處理,應用程序不用關心內核是如何處理的,內核處理完會把執行流程還給應用程序,同時根據執行成功與否設置全局變量 errno 的值。通常狀況下,在 linux 上系統調用成功會返回非負值,發送錯誤時會返回負值。

接下來的指令實際上執行 exit(0) 退出程序,指令和邏輯與以前的同樣,再也不贅述。

下面來編譯和執行上面的彙編代碼。在 Linux 上,可使用 as 和 ld 彙編和連接程序

as $helloworld.s -o helloworld.o
ld $helloworld.o -o helloworld

執行:
./helloworld
複製代碼

能夠看到輸出了

Hello, World!
複製代碼

Go 語言彙編輸出 Hello World 淺嘗

剛開始接觸 Go 語言彙編的時候一臉懵逼,這都是些啥,竟然用的是一個歷來沒據說過的操做系統 plan9 所自帶的彙編器語法,不過沒有辦法,技術選型永遠是 leader 和 CTO 說了算。

注意下面的實驗是在 Mac 平臺上,源代碼見:github.com/arthur-zhan…

文件結構以下:

.
├── helloworld
│   ├── helloworld.go
│   └── helloworld.s
├── main.go
複製代碼

main.go 的內容以下,調用了 helloworld.go 中的 PrintMe 方法:

package main

import (
	"./helloworld"
)

func main() {
	helloworld.PrintMe()
}
複製代碼

helloworld.go 的內容只是聲明瞭一個 PrintMe() 的空函數:

package helloworld

func PrintMe()
複製代碼

具體的實現是在 helloworld.s 這個彙編文件中,內容以下:

#include "textflag.h"

DATA  msg<>+0x00(SB)/8, $"Hello, W"
DATA  msg<>+0x08(SB)/8, $"orld!\n"
GLOBL msg<>(SB),NOPTR,$16

TEXT ·PrintMe(SB), NOSPLIT, $0
	MOVL 	$(0x2000000+4), AX 	 // write 系統調用數字編號 4
	MOVQ 	$1, DI 			      // 第 1 個參數 fd
	LEAQ 	msg<>(SB), SI 		// 第 2 個參數 buffer 指針地址
	MOVL 	$16, DX 		        // 第 3 個參數 count
	SYSCALL
	RET
複製代碼

雖然指令不太同樣,可是總體的彙編代碼邏輯是同樣的,一樣是分了 Data 段、Text 段,一樣是用 mov 等指令給寄存器賦值。下面簡單介紹一下上面的彙編代碼,後面的文章會有更詳細的介紹。

關於寄存器

plan9 中使用寄存器不須要帶 r 或 e 的前綴,例如 rax,只要寫 AX 就能夠了。

eax->AX
ebx->BX
ecx->CX
...
複製代碼

Go 彙編引入了四個僞寄存器,這四個僞寄存器很是重要:

  • FP: Frame pointer,用來訪問函數的參數
  • PC: Program counter: 用於分支和跳轉
  • SB: Static base pointer: 通常用於聲明函數或者全局變量
  • SP: Stack pointer:指向當前棧幀的局部變量的開始位置,通常用來引用函數的局部變量

變量聲明

Go 彙編語言中 DATA 命令用於初始化變量,語法以下:

DATA symbol+offset(SB)/width, value
複製代碼

好比聲明 msg 這個變量:

DATA  msg<>+0x00(SB)/8, $"Hello, W"
複製代碼

下面來看 GLOBL 指令

GLOBL msg<>(SB),NOPTR,$16
複製代碼

GLOBL 指令將變量聲明爲 global,後面須要跟兩個參數,flag 和變量的大小,這的 NOPTR 不影響後面的閱讀,這裏先不作介紹。

注意到 msg 後面有一個<>,這表示這個全局變量只在當前文件中能夠被訪問,相似於 C 語言中的 static。

函數定義

函數定義的語法以下:

TEXT symbol(SB), [flags,] $framesize[-argsize]
複製代碼

分爲 5 個組成部分:TEXT 指令、函數名、可選的 flags 標誌、函數幀大小和可選的函數參數大小

以例子中的彙編代碼爲例:

TEXT ·PrintMe(SB), NOSPLIT, $0
複製代碼
  • TEXT 表示是彙編中的 .text 分段,
  • 注意到 TEXT 和 PrintMe中間除了一個空格之外,還有一個反人類的「中點·」,不知道當初設計這個的人是有一種什麼樣的癖好,😁。這個中點在編譯之後會被替換爲.,同時也會加上包名,好比這裏的 helloworld.PrintMe
  • NOSPLIT 標誌位這裏先不介紹
  • $0 表示棧幀大小爲 0

接下來的就是具體的函數體的內容。

MOVL $(0x2000000+4)中的 0x2000000 是什麼鬼?Mac 下的系統調用數字編號須要加 0x2000000,不要問爲何,問就是系統約定。Mac 下的系統調用編號能夠在這裏查:opensource.apple.com/source/xnu/…

與前面介紹的 Linux 下的彙編稍有不一樣,Mac 下的系統調用參數須要存儲在 DI、SI、DX 等寄存器中,系統調用編號存儲在 AX 中。

Go 的 HelloWorld 彙編入門就先介紹到這裏。但願對你有所幫助

後記

這篇文章做爲 Go 語言彙編的入門,由於篇幅有限,沒有很是細緻的展開每個細節,在後面的系列文章中,咱們會繼續結合案例進行介紹。

能夠掃描下面的二維碼關注個人公衆號:

相關文章
相關標籤/搜索