博客 / 詳情

返回

如何使用 go:linkname 指令訪問 Go 包中的私有函數

公眾號首發:https://mp.weixin.qq.com/s/nzbuLHfS4Nu2qtcd2bO6-w

在 Go 語言的包設計中,函數和變量通過首字母大小寫來嚴格區分導出(exported)與未導出(unexported)的可見性規則。這種機制是 Go 模塊化設計的基石,但同時也為底層系統級開發帶來了限制。//go:linkname 指令正是 Go 為突破這一限制預留的「後門」,它通過編譯器的符號重定向能力,允許開發者直接鏈接任意包的未導出符號——無論是標準庫的私有函數,還是第三方包的隱藏變量。

本文就來帶大家一起體驗下 //go:linkname 指令的魔力。

go:linkname 指令簡介

//go:linkname 是 Go 語言中的一個編譯器指令,用於在編譯階段將當前包內的函數或變量與另一個包中函數或變量(即使是未導出的)進行鏈接。

語法格式如下:

//go:linkname localname [importpath.name]

例如,我們要自定義一個 FastRand 函數並鏈接到 runtime 包的私有函數 fastrand,可以這樣寫:

//go:linkname FastRand runtime.fastrand
func FastRand() uint32

這樣,我們只需要聲明 FastRand 函數,而無需實現,當調用 FastRand() 時就會自動執行 runtime.fastrand() 的調用。

//go:linkname 指令支持 3 種模式,Pull 模式、Push 模式以及 Handshake 模式,這 3 種模式會在下文中依次講解。

準備工作

我準備瞭如下目錄結構,用於演示 //go:linkname 指令的功能。

$  tree linkname   
linkname
├── bar
│   └── bar.go
├── foo
│   ├── dummy.s
│   └── foo.go
├── go.mod
└── main.go

3 directories, 5 files

main.go 是程序入口,foo/foo.go 用來定義程序的導出函數,bar/bar.go 用來定義程序的未導出函數。所以 main.go 不會直接導入 bar/bar.go,而是通過導入 foo/foo.go 間接與 bar/bar.go 交互。

Pull 模式

Pull(拉取)模式下 bar 包中的函數無需任何特殊處理,foo 包使用 //go:linkname 指令鏈接到 bar 包中的函數。示例如下:

首先在 bar/bar.go 中實現一個 add 函數:

https://github.com/jianghushinian/blog-go-example/blob/main/directive/linkname/bar/bar.go
package bar

func add(a, b int) int {
    return a + b
}

然後在 foo/foo.go 中使用 //go:linkname 指令鏈接到 bar 包中的 add 函數:

https://github.com/jianghushinian/blog-go-example/blob/main/directive/linkname/foo/foo.go
package foo

import (
    _ "unsafe"

    // 被拉取的包需要顯式導入(除了 runtime 包)
    _ "github.com/jianghushinian/blog-go-example/directive/linkname/bar"
)

// Pull 模式(拉取外部實現)

//go:linkname Add github.com/jianghushinian/blog-go-example/directive/linkname/bar.add
func Add(a, b int) int

這裏有兩點需要注意:

  1. 被拉取的包需要顯式導入到當前包中(runtime 包除外),所以這裏使用了匿名導入的方式導入 bar 包。
  2. 要使用 //go:linkname 指令,必須要導入 unsafe 包,因為這是一個不安全的操作,所以這裏同樣使用匿名導入的方式導入 unsafe 包。

這裏聲明瞭 Add 函數但並未實現,並使用 //go:linkname 指令將 Add 函數鏈接到 bar.add 函數。

這就完成了 Pull 模式。

最後我們編寫一個 main 函數來測試下效果:

https://github.com/jianghushinian/blog-go-example/blob/main/directive/linkname/main.go
package main

import (
    "fmt"

    "github.com/jianghushinian/blog-go-example/directive/linkname/foo"
)

func main() {
    fmt.Println("foo.Add(1, 2):", foo.Add(1, 2))

}

我們可以像正常函數一樣導入並使用 foo.Add 函數。

執行示例代碼,得到輸出如下:

$ go run main.go                          
foo.Add(1, 2): 3

看起來一切正常。

不過,這種模式存在極大的安全隱患,在這種模式中,bar 包可能並不打算讓 foo 使用其 add 函數,bar 包也不知道會有其他包鏈接它的函數,當維護 bar 包的人修改了 add 函數簽名,則 foo 包就無法運行了。

由此我們可以嘗試換一種方式進行鏈接,使用 Push 模式。

Push 模式

Push(推送)模式下 bar 包使用 //go:linkname 指令將定義的未導出函數“重命名”為 foo 包中的導出函數,foo 包中只需要聲明函數簽名即可。示例如下:

首先在 bar/bar.go 中實現一個 div 函數:

https://github.com/jianghushinian/blog-go-example/blob/main/directive/linkname/bar/bar.go
package bar

import (
    _ "unsafe"
)

// Push 模式(導出本地實現)

//go:linkname div github.com/jianghushinian/blog-go-example/directive/linkname/foo.Div
func div(a, b int) int {
    return a / b
}

這裏使用 //go:linkname 指令將未導出函數 div “重命名”為 foo.Div

然後我們在 foo/foo.go 中聲明 Div 函數:

https://github.com/jianghushinian/blog-go-example/blob/main/directive/linkname/foo/foo.go
package foo

import (
    // 被拉取的包需要顯式導入(除了 runtime 包)
    _ "github.com/jianghushinian/blog-go-example/directive/linkname/bar"
)

func Div(a, b int) int

這裏同樣需要顯式導入 bar 包,並且只聲明 Div 函數而不做實現。

最後同樣編寫一個 main 函數來測試下效果:

https://github.com/jianghushinian/blog-go-example/blob/main/directive/linkname/main.go
package main

import (
    "fmt"

    "github.com/jianghushinian/blog-go-example/directive/linkname/foo"
)

func main() {
    fmt.Println("foo.Div(2, 1):", foo.Div(2, 1))
}

現在,我們來嘗試執行下這個示例,得到輸出如下:

$ go run main.go
# github.com/jianghushinian/blog-go-example/directive/linkname/foo
foo/foo.go:8:6: missing function body

這裏得到了報錯,提示 foo.Div 函數沒有 body 體。

解決辦法也很簡單,我們只需要在 foo.go 同級目錄下加上一個內容為空的 dummy.s 文件就好了。這是 Go 語言的約定,你知道就好,其實這個文件名叫什麼無所謂,只要是表示彙編程序的 .s 結尾的文件就可以。

再次執行示例程序,得到輸出如下:

$ go run main.go
foo.Div(2, 1): 2

這種模式下我們在 bar 包中主動暴露了未導出函數 bar.div,這樣 bar 包就可以明確的知道自己會鏈接 foo.Div 函數。不過,這種模式帶來了新的問題,就是需要引入一個空的 dummy.s 文件。

那麼有沒有更好的方式呢?當然,答案就是 Handshake 模式。

Handshake 模式

Handshake(握手)模式故名思義,需要鏈接雙方共同參與,攜手實現最終目標。Handshake 模式下 bar 包使用 //go:linkname 指令將定義的未導出函數標記為允許被外部包鏈接,foo 包同樣使用 //go:linkname 指令鏈接到 bar 包中的函數。示例如下:

首先在 bar/bar.go 中實現一個 hello 函數:

https://github.com/jianghushinian/blog-go-example/blob/main/directive/linkname/bar/bar.go
package bar

import (
    _ "unsafe"
)

// Handshake 模式(雙方握手模式)

//go:linkname hello
func hello(name string) string {
    return "Hello " + name + "!"
}

這裏使用 //go:linkname hello 指令標記為允許外部包進行鏈接。

接着在 foo/foo.go 中聲明 Hello 函數:

https://github.com/jianghushinian/blog-go-example/blob/main/directive/linkname/foo/foo.go
package foo

import (
    _ "unsafe"

    // 被拉取的包需要顯式導入(除了 runtime 包)
    _ "github.com/jianghushinian/blog-go-example/directive/linkname/bar"
)

// Handshake 模式(雙方握手模式)

//go:linkname Hello github.com/jianghushinian/blog-go-example/directive/linkname/bar.hello
func Hello(name string) string

這裏並沒有新的寫法,與 Pull 模式寫法相同。不過需要注意的是,Handshake 模式中雙方都需要引入 unsafe 包。

最後編寫一個 main 函數來測試下效果:

https://github.com/jianghushinian/blog-go-example/blob/main/directive/linkname/main.go
package main

import (
    "fmt"

    "github.com/jianghushinian/blog-go-example/directive/linkname/foo"
)

func main() {
    fmt.Println(`foo.Hello("jianghushinian"):`, foo.Hello("jianghushinian"))
}

執行示例代碼,得到輸出如下:

$  go run main.go
foo.Hello("jianghushinian"): Hello jianghushinian!

可以發現,Handshake 模式其實就是 Pull 模式和 Push 模式的結合體。這種模式語義更加明確,且無需提供 dummy.s 文件,也是 Go 最推薦的寫法。

內置包限制

事實上,從 Go 1.23 版本起,Go 已經不推薦 Pull 模式和 Push 模式了,僅推薦使用 Handshake 模式,具體原因,你可以在 issues/67401 中查看。

由此,也引來的一個問題,那就是在 Go 1.23+ 版本中,如果使用 Pull 模式鏈接 Go 內置包,則默認情況下程序無法通過編譯。

示例如下:

package main

import (
    "fmt"
    _ "unsafe"
)

//go:linkname TooLarge fmt.tooLarge
func TooLarge(x int) bool

func main() {
    fmt.Println("TooLarge(1e6 + 1):", TooLarge(1e6+1))
}

這裏使用 //go:linkname 指令將 TooLarge 函數鏈接到內部包函數 fmt.tooLarge

執行示例代碼,得到輸出如下:

$ go run main.go
# command-line-arguments
link: main: invalid reference to fmt.tooLarge

要解決這個問題,需要提供編譯新的編譯指令 -checklinkname=0,用法如下:

$ go run -ldflags=-checklinkname=0 main.go
TooLarge(1e6 + 1): true

這樣,程序就可以正常執行了。

當然,根據我的實測結果,如果把 TooLarge 函數的定義遷移到 foo/foo.go 中,然後在 main.go 中以 foo.TooLarge(1e6+1) 的方式使用,則沒任何問題,無需提供編譯指令 -checklinkname=0

總結

//go:linkname 指令用於在編譯階段將當前包內的函數或變量與另一個包中函數或變量(即使是未導出的)進行鏈接,突破了 Go 中未導出標識在外部無法訪問的限制。不過 //go:linkname 指令雖然強大,但需要慎用,畢竟它是“unsafe”的,除非你知道自己在幹什麼。

本文中演示了 //go:linkname 指令對函數的作用,關於對變量的應用讀者可以自行嘗試。

雖然 Go 中的 //go:linkname 指令支持 3 種模式:Pull 模式、Push 模式和 Handshake 模式,不過從 Go 1.23 版本起,已經只推薦使用 Handshake 模式了。至於具體原因,你可以訪問 issues/67401 中查看,我就不囉嗦了。

本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。

希望此文能對你有所啓發。

聯繫我

  • 公眾號:Go編程世界
  • 微信:jianghushinian
  • 郵箱:jianghushinian007@outlook.com
  • 博客:https://jianghushinian.cn
  • GitHub:https://github.com/jianghushinian
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.