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

這兩行代碼的作用是:

  1. 先把 ivBytes,keyBytes 這兩個 slice 轉成 string 類型,然後打印出來,

  2. 而 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 分析下,或者找我交流。

當然了,這個讀者朋友也是在研究和學習當中,現實項目中應該不至於出現這種奇怪的代碼。但是這個小問題引發的思考卻值得記錄和學習。

總結

  1. Go 並非沒有踩內存和其他內存問題,只要你想越過 Go 的類型校驗,那麼在 C 語言編程中遇到的所有的內存問題可能你都要面對;

  2. unsafe 這個庫已經這麼明顯的名字了,就是告訴你不安全,謹慎使用。所以說,除非有必須要用的理由,且明確知道自己的行爲導致的後果纔去使用,否則繞開走吧;

  3. string 的管理結構是 16 字節,slice 的管理結構是 24 字節,記住哦。在以上的例子,這個變量本身在哪分配?調試下;

  4. 感謝這位讀者的問題,以上這個例子僅僅用來學習,生產一般不會寫這樣的奇怪強轉的代碼;

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/I0gfFDaEUM85YIXJ6o3WmQ