在看一些其它語言實現的基礎工具時,時而發現其中有我們需要的某項特殊功能。究其源碼,一般會看到兩種底層實現:彙編、系統調用。這裡的系統調用就是我們今天的主角了。
系統調用
image.png
系統調用在作業系統中佔有重要的地位,是核心對外互動的門戶,為我們提供了與底層資源互動的相對簡單、安全的方式,給我們提供了一種在使用者態、核心態切換的手段。
我們寫的程式,通常是跑在使用者態的,它對應 CPU 的 Ring 3 保護層級,而核心運行在 Ring 0 層級,擁有更高的許可權。相應的,核心的代碼可以運行一些使用者態代碼無法啟動並執行 CPU 特權指令,實現一些使用者態的代碼做不到的事情,比如:控制進程的運行,使用驅動操作機器上的硬體。核心將部分自己實現的功能進行封裝, 形成相對統一、方便的介面給我們進行調用,這些介面就是系統調用。
通常,我們使用某些特殊指令來通知核心去執行這些系統調用的對應代碼,如:Int 0x80、sysenter、syscall。核心收到這些指令後會根據我們進程給出的參數,執行對應的功能。這時,我們的進程也會從使用者態切換到核心態。
Golang 中 syscall 的實現
開啟 godoc 中 syscall
包的文檔,可以看到標準庫給這些系統調用做了不錯的封裝,不少常用的系統調用已經可以像普通函數一樣直接調用了,除此之外,還提供了 4 個通用的封裝方式,供我們執行任意的系統調用:
Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)
從外觀觀察,可以知道它們可以按支援的參數個數分成兩類:
- 供 4 個及 4 個以下參數的系統調用使用的
Syscall
、RawSyscall
- 供 6 個及 6 個以下參數的系統調用使用的
Syscall6
、RawSyscall6
而從對我們來說更有意義的實現、功用的角度看,可以分為 Syscall
、RawSyscall
兩類。
Syscall
廢話不多說,讓我們來看下 Syscall
的具體實現:
// func Syscall(trap int64, a1, a2, a3 int64) (r1, r2, err int64);// Trap # in AX, args in DI SI DX R10 R8 R9, return in AX DX// Note that this differs from "standard" ABI convention, which// would pass 4th arg in CX, not R10.TEXT ·Syscall(SB),NOSPLIT,$0-56 CALL runtime·entersyscall(SB) MOVQ a1+8(FP), DI MOVQ a2+16(FP), SI MOVQ a3+24(FP), DX MOVQ $0, R10 MOVQ $0, R8 MOVQ $0, R9 MOVQ trap+0(FP), AX // syscall entry SYSCALL CMPQ AX, $0xfffffffffffff001 JLS ok MOVQ $-1, r1+32(FP) MOVQ $0, r2+40(FP) NEGQ AX MOVQ AX, err+48(FP) CALL runtime·exitsyscall(SB) RETok: MOVQ AX, r1+32(FP) MOVQ DX, r2+40(FP) MOVQ $0, err+48(FP) CALL runtime·exitsyscall(SB) RET</pre>
這段彙編中,主要執行了 6 個步驟:
- 調用
runtime.entersyscall
函數。通知 runtime 調度器,讓出已耗用時間
- 讀記憶體,把各個參數放到合適的寄存器
- 通知核心執行系統調用
- 判斷系統調用的執行結果,並進行跳轉
- 若執行成功,拷貝執行結果到傳回值。若執行失敗,置空傳回值
- 調用
runtime.exitsyscall
函數,恢複該 goroutine 的運行
RawSyscall
RawSyscall
的彙編實現與 Syscall
一致,唯一的區別是沒有調用 runtime.entersyscall
和 runtime.exitsyscall
,也就是說,直接使用 RawSyscall
可能出現阻塞的情況。
提到阻塞就不得不解釋下,系統調用可以分兩種:快系統調用、慢系統調用。快系統調指的是不會造成阻塞的系統調用,如:擷取 pid。相應的,慢系統指的就是會造成阻塞的系統調用,如:讀寫磁碟、網路。雖然平時可能感覺這些慢系統調用也執行的很快,但它們的速度相比 CPU 還是太慢,在某些情形下,這個速度還會被放慢很多,甚至出現假死(hang)的情況。
因此,正如 golang 郵件清單裡的討論所言,除非你對你要用的具體系統調用非常瞭解,同時效能要求極高,其它情境下能別用就別用 RawSyscall
。
I would say that Go programs should always call Syscall. RawSyscall exists to make it slightly more efficient to call system calls that never block, such as getpid. But it’s really ann internal mechanism.
syscall 庫的產生
觀察 syscall 庫源碼檔案的分布,可以看到除了一堆尾碼名為 .s
、.go
的檔案,還有一些尾碼名為 .sh
、.pl
的檔案,這些就是 syscall 庫部分封裝代碼的自動產生指令碼。
瀏覽這些檔案可以知道,golang 中的 syscall 封裝是自動完成的,主要方式是使用 gcc
對 /usr/include/x86_64-linux-gnu/asm/unistd_64.h 進行處理,再對處理的結果進行文本替換,產生平台相關的源碼檔案。
執行系統調用
有了基本的瞭解之後,我們就可以進行一些嘗試了,嘗試之前先申明下,系統調用是個與作業系統強相關的東西,不同平台的使用方式不同,這裡的描述只保證在 linux
amd64
平台下有效。同時,系統調用使用不當,可能使作業系統出現某些不正常的行為,使用之前需要閱讀對應的系統調用的具體描述。
常用系統調用
Golang 的 syscall 庫已經對常用系統調用進行了封裝,我們只需要調用相應的函數,並傳入相應的參數就可以等著執行完成,給我們返回需要的結果了。
等等,這裡需要我們要傳入對應的參數,還有多個傳回值,這些參數該怎麼填,各個傳回值又是什麼含義呢?很可惜,syscall 庫並沒有對這些內容做必要的介紹,也就是說我們需要自行尋找一個資料,提供對每個系統調用進行詳細描述的相對權威的描述。
man
這個我們平日裡經常用到的命令,除了提供各種命令的使用協助,還提供了不少系統層面的資料,其中就有我們所需要的各個系統調用的具體描述。通過比對 man
裡的資料與封裝函數的外觀,我們可以得到具體系統調用的對應實踐方式。
我們除了可以在命令列直接使用 man
命令進行離線查閱,還可以在 man7.org 進行線上查詢,方便在開發、啟動並執行環境不同的情況下使用。
mmap
多說無益,我們來做個嘗試,在實現過程中來體會具體的實踐方式。這裡,我們選擇使用 mmap
來實現資料的持久化儲存作為樣本。
首先我們需要查閱資料,對 mmap
有個基本瞭解,知道它將檔案對應進記憶體的基本原理,以及相比傳統的檔案讀寫方式的優劣勢。
然後,查看標準庫對 mmap
這個系統調用的封裝:
func Mmap(fd int, offset int64, length int, prot int, flags int) (data []byte, err error)
接著,我們查看 man
對 mmap 的介紹 :
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
這下,兩邊就能夠對應上了,讓我們來瞭解一下各個參數的具體定義:
- fd:映射進記憶體的檔案描述符
- offset:映射進記憶體的檔案段的起始位置,在檔案中的位移量
- length:映射進記憶體的檔案段的長度,必須是正整數
- prot:protection 的縮寫,用來做許可權控制,golang 標準庫已有預定義的值
- flags:對
mmap
的一些行為進行控制,golang 標準庫已有預定義的值
同樣,傳回值也可以對應得上,不過,在形式上進行了一些轉變,需要進行理解和翻譯:
- data:對應
*addr
,返回映射進記憶體的檔案段對應的數組,持久化資料就是使用這個數組
- err:對應這個函數的傳回值
void
,傳回值的含義,在 golang 已有對應的定義
我們來試一下,根據這個文檔寫出實現代碼:
func main() { f, err := os.OpenFile("mmap.bin", os.O_RDWR|os.O_CREATE, 0644) if nil != err { log.Fatalln(err) } // extend file if _, err := f.WriteAt([]byte{byte(0)}, 1<<8); nil != err { log.Fatalln(err) } data, err := syscall.Mmap(int(f.Fd()), 0, 1<<8, syscall.PROT_WRITE, syscall.MAP_SHARED) if nil != err { log.Fatalln(err) } if err := f.Close(); nil != err { log.Fatalln(err) } for i, v := range []byte("hello syscall") { data[i] = v } if err := syscall.Munmap(data); nil != err { log.Fatalln(err) }}
編譯並執行這段代碼,會在目前的目錄產生 mmap.bin
檔案,執行 hexdump -C mmap.bin
可以看到,檔案裡面已有我們寫入的內容。
任意系統調用
執行任意系統調用的實踐方式與執行常用系統調用類似。不過,沒被封裝的系統調用,一般都是使用情境很少的系統調用,這就意味著能找到的資料少,man
裡面的資料也未必齊全。
資料少不代表沒有,golang 的資料找不到,不妨找一找 C/C++ 相關的實踐,也可以直接去看執行了該系統調用的開源項目的源碼。甚至,在極端情況下,我們可以直接查看該系統調用對應的核心源碼。這裡推薦使用 https://syscalls.kernelgrok.com 來快速定位具體系統調用的在核心源碼中的具體位置。不過,這些收集資料的方式,對我們的作業系統知識、C 系語言源碼的閱讀能力要求較高。
找到足夠的資料,就可以開始進行實現了。syscall.Syscall
的具體使用方式,可以在一些常用系統調用封裝的源碼中找到答案:
- 第一個參數為系統調用號,一般以
SYS_
開頭。
- 後續的參數就是
man
裡面寫著的各個參數。未必是指標,也可能是一些數字,統一以 uintptr
類型進行傳遞,部分情況需要執行強制類型轉換。
- 在某系統調用需要的參數小於 4 (6) 個的時候,缺少的參數項,用 0 補足
這裡借用 gotty
中,設定 tty 行數、列數的系統調用源碼作為樣本:
window := struct { row uint16 col uint16 x uint16 y uint16 }{ rows, columns, 0, 0, } syscall.Syscall( syscall.SYS_IOCTL, // syscall number context.pty.Fd(), syscall.TIOCSWINSZ, // call option uintptr(unsafe.Pointer(&window)), )
是否要使用系統調用
正如本文開頭所描述,系統調用可以直接與核心互動,無疑要比使用 shell
命令與核心進行互動的效率要高。如果標準庫已經對該系統調用做了封裝,直接使用對應的封裝,要比使用 shell
命令的互動方式的優勢更加明顯。
所以,當代碼裡要實現某項功能,並且我們的代碼要作為一個長期穩定啟動並執行服務運行時,應盡量使用系統調用,而不是在源碼中執行 shell
命令進行實現。相反,如果只是寫一些臨時的,對效率要求不高的工具時,哪個方便用哪個。
這裡需要注意,在我們的源碼中調用第三方工具,我們要為這些第三方工具的正確性負責。一是要保證以正確的方式使用,二是在第三方工具的內部實現有 bug 時,我們要有相應的能力來分析與診斷相應的問題。