Go 編程怎麼也有踩內存?
作者 | 奇伢 責編 | 歐陽姝黎
前情概要
有位讀者羣裏拋出過一段自己研究的代碼,並附上這麼一個問題:
讀者朋友貼出的代碼截屏:
爲了剛好的研究,下面貼出來代碼文本:
package main
import (
"fmt"
"reflect"
"unsafe"
)
func StringToByte(key *string) []byte {
strPtr := (*reflect.SliceHeader)(unsafe.Pointer(key))
strPtr.Cap = strPtr.Len
b := *(*[]byte)(unsafe.Pointer(strPtr))
return b
}
func main() {
decryptContent := "/AvYEjm4g6xJ3LVrk2/Adk"
iv := decryptContent[0:16]
key := decryptContent[2:18]
fmt.Println(&iv)
fmt.Println(&key)
ivBytes := StringToByte(&iv)
keyBytes := StringToByte(&key)
fmt.Println(string(ivBytes))
fmt.Println(string(keyBytes))
}
思考第一個問題:爲什麼會報錯?
我自己也編譯跑了下,確實是得到如下錯誤。爲什麼會出現這個問題?其實文章標題都已經說明了,就是踩內存。那現在我們就是要先分析怎麼踩的內存?
sh-4.4# ./test
0xc0000821e0
0xc0000821f0
/AvYEjm4g6xJ3LVr
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x10 pc=0x45d1c6]
goroutine 1 [running]:
main.main()
/home/qiya/test.go:25 +0x37f
之前我在深入剖析 Go nil 的文章裏有提到過,一定要理解變量結構體本身和被管理的結構。
字符串類型的變量,本身佔用 16 字節,有一個指針指向一塊內存,這個內存纔是字符串存儲的位置,還有一個長度字段標識字符串的長度。如下:
type string struct {
uint8 *str;
int len;
}
**slice 的變量本身佔用 24 字節,**有 3 個 8 字節的字段。Data 指向一個 byte 內存塊,Len 標識當前有效的元素位置,Cap 標識這個動態數組的物理長度。
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
現在我們在仔細看下程序裏面的 StringToByte 函數,我們看到在 main 函數里,直接取了 key,iv 這兩個變量本身的地址,作爲參數傳進了 StringToByte ,隨後在這個函數里,把這個地址通過 unsafe 庫強轉類型,當作 slice 管理結構來用,重點來了,且覆蓋寫 strPtr.Cap 這個字段。這就踩內存了,往後多踩了 8 字節的內存。
我就代碼執行一步步分析下:
執行完以下代碼:
decryptContent := "/AvYEjm4g6xJ3LVrk2/Adk"
iv := decryptContent[0:16]
key := decryptContent[2:18]
內存棧如下:
在看 22,23 的代碼:
ivBytes := StringToByte(&iv)
keyBytes := StringToByte(&key)
然後,經過 22 行代碼執行第一個 StringToByte 函數之後(也就是第一次踩內存),由於傳進去的是變量 iv 的地址,於是在函數 StringToByte 裏往後多踩了 8 字節,也就是說把變量 key 的頭 8 字節踩掉了。效果如下:
key 的頭 8 個字節原本是個地址,指向堆上內存字符串所在位置。現在卻被無情的踩成了一個整數 16 。
然後, StringToByte 函數把這 24 個字節複製給一個棧上的局部變量 ivBytes , ivBytes 的變量值如下:
*str => 0x4c253b
len => 16
cap => 16
接着,運行了第 23 行,又執行了一次 StringToByte 的函數,這次傳入的地址是 key 變量的地址,又是往後踩了 8 字節。這次踩到誰了?看一眼 key 變量裏現在的內容,哈哈哈,如下:
(gdb) x/14gx 0xc0000821f0
0xc0000821f0: 0x0000000000000010 0x0000000000000010
0xc000082200: 0x0000000000000010 0x0000000000000000
0xc000082200 這個地址也是個無妄之災,[汗]。可以看到 key 變量本身變成雙 16 了。我們再看一樣棧上變量的樣子:
然後, StringToByte 函數把這 24 個字節複製給一個棧上的局部變量 keyBytes , keyBytes 的變量值如下:
*str => 16 (因爲 key 被前面的人踩的,複製的時候就是 16)
len => 16
cap => 16
然後, 局部變量 keyBytes , ivBytes 的也是棧上的變量,結合一起看一下:
繼續往後看,24,25 行:
fmt.Println(string(ivBytes))
fmt.Println(string(keyBytes))
這兩行代碼的作用是:
-
先把 ivBytes,keyBytes 這兩個 slice 轉成 string 類型,然後打印出來,
-
而 string(ivBytes) 調用的函數是 runtime.slicebytetostring
彙編能夠看到 string(ivBytes) 實際的調用函數,如下:
0x0000000000491d2f <+671>: callq 0x447f70 <runtime.slicebytetostring>
再看一樣 slicebytetostring 原型如下:
func slicebytetostring(buf *tmpBuf, b []byte) (str string) {
}
先說一下第一個參數,這是一個指針,這個指針就是 sliceheader 的第一個字段,也就是 Data 指針字段。
繼續看 24 行代碼的運行,這行代碼爲什麼不會報錯,因爲 ivBytes 變量沒事。ivBytes 能夠轉成 string ,其中 ivBytes 的變量如下:
*str => 0x4c253b
len => 16
cap => 16
但是 25 行代碼則會出 panic(還記得嗎?我們文章最開始的截圖,panic 的位置就是 25 行),但是變量 key 被踩了呀,導致 keyBytes 這個變量也是錯的。
*str => 16
len => 16
cap => 16
本應該是指針的字段,卻活生生被踩成了 16 ,然後把這個值 16 當作指針傳遞到 slicebytetostring 函數里去轉類型,如果這都不出非法地址的 panic ,那才真的是神奇了。
明明參數是指針,但是卻傳了一個 16 進去,這個就是爲什麼出 panic 的原因了。
思考第二個問題:爲什麼 22,23 調換下順序就可以了?
怎麼踩的內存,已經清楚了,但這位讀者朋友,又深入問了一句:
是啊,爲什麼?其實你只需要像上面一樣,畫一張圖就就很簡單了。22,23 調換一下順序的區別就只在於換成先踩了誰的內存而已。
keyBytes := StringToByte(&key)
ivBytes := StringToByte(&iv)
由於 keyBytes := StringToByte(&key) 執行的時候踩的是後面的地址,但是 iv 地址是在 key 前面的,所以沒有被踩到,是完好的內存,把 key 後面不知名的 8 字節踩了。最後 keyBytes 值如下:
*str => 0x4c253d
len => 16
cap => 16
所以,在執行的 ivBytes := StringToByte(&iv) 的時候 ivBytes 的值正常:
*str => 0x4c253b
len => 16
cap => 16
所以你會發現,調換順序後 ivBytes 和 keyBytes 兩個變量本身的內容都是好的。因爲先踩的是 key 後面的內存,沒有影響到 ivBytes 的構造。
當然了,最後還是順手把變量 key 的頭部踩成 16 了,不過此時已經沒有影響,因爲下面用的是 keyBytes, ivBytes ,所以程序自然可以正常運行。
思考第三個問題:怎麼才能把程序改正確?
修改了兩行代碼,如下:
`package main
import (
"fmt"
"reflect"
"unsafe"
)
func StringToByte(key *string) []byte {
slic := reflect.SliceHeader{}
slic = *(*reflect.SliceHeader)(unsafe.Pointer(key))
slic.Cap = slic.Len
b := ([]byte)(unsafe.Pointer(&slic))
return b
}
func main() {
decryptContent := "/AvYEjm4g6xJ3LVrk2/Adk"
iv := decryptContent[0:16]
key := decryptContent[2:18]
fmt.Println(iv)
fmt.Println(key)
ivBytes := StringToByte(&iv)
keyBytes := StringToByte(&key)
fmt.Println(string(ivBytes))
fmt.Println(string(keyBytes))
}
`
上面的程序我只改動了 StringToByte 函數,只改了 2 行代碼。
slic := reflect.SliceHeader{}
slic = *(*reflect.SliceHeader)(unsafe.Pointer(key))
加了這兩行代碼之後,就不會踩到外面的內存了,**因爲這樣先在棧上分配出 24 字節的局部變量,然後是在這個局部變量上賦值的,**從而杜絕踩內存導致的奇怪問題。具體原理小夥伴如果還有不清楚的,可以自己 gdb 分析下,或者找我交流。
當然了,這個讀者朋友也是在研究和學習當中,現實項目中應該不至於出現這種奇怪的代碼。但是這個小問題引發的思考卻值得記錄和學習。
總結
-
Go 並非沒有踩內存和其他內存問題,只要你想越過 Go 的類型校驗,那麼在 C 語言編程中遇到的所有的內存問題可能你都要面對;
-
unsafe 這個庫已經這麼明顯的名字了,就是告訴你不安全,謹慎使用。所以說,除非有必須要用的理由,且明確知道自己的行爲導致的後果纔去使用,否則繞開走吧;
-
string 的管理結構是 16 字節,slice 的管理結構是 24 字節,記住哦。在以上的例子,這個變量本身在哪分配?調試下;
-
感謝這位讀者的問題,以上這個例子僅僅用來學習,生產一般不會寫這樣的奇怪強轉的代碼;
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/I0gfFDaEUM85YIXJ6o3WmQ