從內存佈局上看,Rust 的胖指針到底胖在棧上還是堆上?

作者 | 馬超       責編 | 張紅月

出品 | CSDN 博客

最近我在前輩巨師的帶領下,也進入到學習 Rust 的大軍中,與其它語言一樣,Rust 最初的爬坡難點也在於字符串方面的處理。雖然說 Rust 與 C 一樣也有指針概念,但是在字符串方面引用了胖指針,關於胖指針的內存佈局,被引用最爲廣泛的一幅說明圖如下:

咱們先來說明一下這個胖指針的大致概念,字符串 s1 有三個元素分別是 ptr、len、capacity,其中 ptr 是指向堆上實際字符串 value 的指針,len 代表字符串的長度,capacity 代表字符串的容量。這些值全部都存在棧上,而實際字符串的值則存在堆上。爲了讓便於說明,我轉化了一下上面的圖,大家可以看一下這幅圖。

對於這幅圖的理解真可謂是一波三折,我一開始以爲這圖畫的不對,後來發現應該是對的,最後深入研究還是發現了一個小問題,最終正確的示意圖如下:

本文就和大家分享一下具體分析的過程。

胖指針理解錯誤的起因

我們知道 Rust 在編譯是可以通過 - g 參數保留符號信息,再通過 objdump 命令就可以將代碼對應的彙編語言導出,具體指令如下:

rustc -g 文件名.rs
objdump -S 文件名

先來看一下代碼

fn main() {
    let mut s1 = String::from("hello");
    let len = calculate_length(&s1);
    println!("The Length is {}.", len);
}
fn calculate_length(s: &String) -> usize {
    s.len()
}

將上述代碼中字符串值進行微調之後的代碼

fn main() {
  let mut  s1=String::from("hell00");
  let len = calculate_length(&s1);
   println!("The Length is {}.",len);
 }
fn calculate_length(s:&String)->usize{
  s.len()
}

在得到相應的彙編代碼以後,diff 一下結果如下:

2991c2991
<         let mut  s1=String::from("hello");
---
>         let mut  s1=String::from("hell00");
2994c2994
<     a9f3:     ba 05 00 00 00          mov    $0x5,%edx
---
>     a9f3:     ba 06 00 00 00          mov    $0x6,%edx

也就是說從執行碼也就是彙編的角度上看,只有執行 mov    $0x6,%edx 時,傳遞的參數一個是 5 一個是 6,棧上的操作似乎只涉及長度 len,這讓我初步對於 capacity 這個值的存放位置產生了一定懷疑。

接下來我又用 gdb 調用了一下上面這個程序,其中 print s1 的結果如下

(gdb) print s1
$2 = {
  vec = {
    buf = {
      ptr = {
        pointer = 0x5555557a0110 "hello\177",
        _marker = {<No data fields>}
      },
      cap = 5,
      alloc = {<No data fields>}
    },
    len = 5
  }
}

在看到這個信息的時候,我想當然的以爲 cap 是 buf 的一個 item,而 buf 一般放在堆上,因此 cap 應該放在堆上,當時理解的圖如下:

當然現在看這個結論的得出犯了想當然的經驗主義錯誤,沒有進行深入實證。

堆和棧到底是幹嘛的

爲了更好的向大家展示對於胖指針內存而已的驗證方案,這裏先簡要介紹一下基本的彙編及 gdb 調試知識。

1. 堆和棧:這裏先來說一下運行時和編譯時的概念,運行和編譯其實是程序的兩種時態,一些信息是程序運行之前就可以確定了,這種場景就對應編譯時;另一類信息是程序真正運行起來才能確定的,這也就對應運行時。

一般來說棧用來對於分配編譯時就可以確定的內存需求,比如某些運算任務我申請一些變量進行關聯計算,這種場景下對於內存的需求在程序運行前就確定了,這種內存分配通過棧來解決就可以了;而堆則用來解決那些運行時才能確定的內存需求,其中最典型的就是字符串,由於字符串往往是由網絡或者磁盤讀出的,因此編譯時無法確定其具體需求,這種情況下一般要通過堆分配內存。

棧的大小是提前確定的,比如我們在看彙編語言指令時函數的入口都是 sub    $0x**,%rsp 也就是進行棧的構建動作,示例如下:

000000000000aa00 <_ZN6hello14main17h5a48792de9598b5bE>:
aa00:       48 81 ec 98 00 00 00    sub    $0x98,%rsp
let mut  s1=String::from("hello");

而堆上的內存分配是操作系統 malloc 的產物,都是動態分配的,示例如下:

220a3:       ff 25 af 8c 22 00       jmpq   *0x228caf(%rip)        # 24ad58 <malloc@GLIBC_2.2.5>

因此棧的特點就是滿足那些可以提前確定的編譯時內存需求,並且程序員可以不去關心棧上內存的分配與釋放,這些都是由編譯器完成的工作。

而堆的特點則是滿足運行時的內存需求,靈活性強,但是分配與釋放都需要程序員人爲管理。

2.Gdb 調試方法簡要說明:用 gdb 調試 rust 程序也很簡單,只需要在編譯時加上 - g 參數,然後用 gdb 啓動調試就可以了,具體的指令如下:

rustc -g 文件名.rs
gdb 文件名

進入到 gdb 模式後,用 list 指令查看代碼

(gdb) list
1       fn main() {
2               let mut  s1=String::from("hello");
3               let len = calculate_length(&s1);
4               println!("The Length is {}.",len);
5            }
6       fn calculate_length(s:&String)->usize{
7       s.len()
8       }
9

使用 b 加行號設置斷點,如

b 3

使用 r 命令運行程序

r

設置 print 的 pretty 參數爲 on

set print pretty on

查看棧寄存器信息

info reg rsp

打印變量信息

print s1

查看內存信息 x / 長度 xb 內存地址如下:

X/5xb 0x5555557a0110

實錘證明胖指針的確胖在了棧上

說到這裏其實相應的準備知識也就都有了。這裏我們只需要進入到 gdb 去具體看一下情況就可以了。

  1. 確定棧空間位置:我們先按照上述 gdb 調試方法執行到第 5 步,確定 rsp 也就是棧頂的位置如下:

從構建棧的語句上看從棧頂向下 0x98 的範圍內都是棧空間:

000000000000aa00 <_ZN6hello14main17h5a48792de9598b5bE>:
aa00:       48 81 ec 98 00 00 00    sub    $0x98,%rsp

確定胖指針中的 ptr(指針) 指向位置:接下來我們來看一下,變量 s1 的信息,得到了胖指針結構體中,指針指向的物理地址,並且這裏還是要解釋一下,初看 cap 屬性和 len 屬性的確不屬於一個層級,這也是我一開始產生錯誤認識的原因。

確定 ptr 與字符串值 的實際對應關係:使用我們在上一節 gdb 調試的第 7 步命令,可以看到胖指針中 ptr 指向位置的內容分別對應”hello” 的 ascii 碼,因此可以確定指針指向堆上實際存放字符串的地址,這點沒問題。

查看 s1 對象中 ptr、len 及 cap 屬性的具體內存佈局:我們剛剛已經確定了自棧頂(0x7fffffffe270)向下 0x98 範圍內都屬於棧空間,那麼我們再通過 x 命令查看整個棧空間,具體註釋如下:

可以看到通過 gdb 實際查看我們基本可以確定字符串 s1 的三個屬性 ptr,cap 和 len 都是存在棧上的,而具體字符串的值則在堆上。之前 cap 存在堆上的想法自然也就是錯的了。

極致挑錯,胖指針內存到底如何內存佈局

還有一點沒有確定,上圖中的例子,cap 和 len 都是 5,因此無法知道具體排列順序關係,那麼我們再來看以下代碼:

fn main() {
    let mut s1 = String::new();
    s1.push_str("hello");
    println!("The length now is {}.", s1.len());
    println!("The cap now is {}.", s1.capacity());
    println!("Then addr now is {:p}.", s1.as_ptr());
}

上述代碼運行結果如下:

The length now is 5.

The cap now is 8.

Then addr now is 0x55afa3255110.

可以看到使用 s1.push_str 的方法可能會使 len 與 cap 值不相同,那麼這種情況下也就便於我們進行具體跟蹤了。

實際觀察內存佈局時我們看到,cap 屬性與 ptr 是相領的,而非之前廣爲流傳的圖示中所說 len 與 ptr 相領,雖然這個錯誤不大,但是有關內存佈局還是不能馬虎,因此修改後正確的胖指針示意如下:

以上就是我對於 Rust 胖指針的學習理解過程,歡迎各位讀者一如既往的提出意見,咱們共同進步!

馬超,CSDN 博客專家,阿里雲 MVP、華爲雲 MVP,華爲 2020 年技術社區開發者之星。

原文鏈接:https://blog.csdn.net/BEYONDMA/article/details/118460702

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