Rust: เจาะลึกหน่วยความจำ Stack, Heap และ Virtual memory (Part 1)

Titiwat Be Munintravong
4 min readJan 28, 2022

--

Rust เป็นภาษาในกลุ่ม Low level language เช่นเดียวกับ C/C++ ที่เราต้องคิดเรื่องจัดการหน่วยความจำเอาเอง สิ่งนี้ทำให้ประสิทธิภาพของตัวโปรแกรมทำงานเร็วขึ้น แต่แลกมาด้วยความซับซ้อนที่มากกว่า

ดังนั้นการจะเขียนโปรแกรมด้วย Rust ให้ดี จำเป็นต้องมีความเข้าใจในเรื่องหน่วยความจำให้ดีด้วย บทความนี้เราจะเข้ามาสำรวจหัวข้อเกี่ยวกับหน่วยความจำกันแบบเจาะลึก เพื่อหาคำตอบสำคัญเหล่านี้

  • Stack, Heap คืออะไร, แตกต่างกันอย่างไร
  • ทำไม Stack ถึงเร็วกว่า Heap
  • การใช้ Box ซึ่งเป็น Smart pointer อันหนึ่งของ Rust ในการจองหน่วยความจำใน Heap ทำอย่างไร
  • ตัวแปรที่เป็น แบบ static lifetime ถูกเก็บไว้อยู่ที่ไหน
  • Virtual Memory คืออะไร,ทำงานอย่างไร, เกี่ยวข้องกับการจัดการหน่วยความจำใน Rust ตรงไหน

ใน Part 1 นี้: เราจะอธิบายหน่วยความจำคอมพิวเตอร์ให้เห็นภาพชัดขึ้น และทำความเข้าใจเรื่อง Virtual Memory

ส่วนใน Part 2 เราจะพูดถึง Stack และ Heap memory ครับ

ลักษณะของหน่วยความจำคอมพิวเตอร์

หน่วยความจำ จะมีลักษณะเหมือนตู้เก็บของที่มีลิ้นชัก ในแต่ละลิ้นชักเก็บของได้แค่ 1 ชิ้น และจะมีหมายเลขระบุไว้ด้านหน้า เพื่อช่วยให้เราจำตำแหน่งสิ่งของที่เราเก็บได้

เพียงแต่หน่วยความจำ RAM ที่ใช้งานจริงในคอมพิวเตอร์นั้น จะมีจำนวนลิ้นชักไว้ให้เก็บของเยอะกว่ามาก แต่ละช่องใช้เก็บข้อมูลขนาด 1 Byte (8bit) โดยสามารถเข้าถึงข้อมูลได้ด้วยการระบุตัวเลขอ้างอิง เรียกว่า ที่อยู่ในหน่วยความจำ (memory address เป็นตัวเลขสีขาวในช่อง)

ในสถาปัตยกรรมคอมพิวเตอร์สมัยใหม่ที่เป็น 64bit จะมี memory address เยอะมาก สามารถไล่ไปถึง 18 ล้านล้านล้านกว่าช่องได้เลย ขึ้นอยู่กับว่า คอมพิวเตอร์เครื่องนั้น ใส่ RAM ที่มีความจุขนาดไหน และมีจำนวนมากน้อยแค่ไหน

ทุกครั้งที่เราสร้างตัวแปรสักตัวใน Rust เช่น

let month: u8 = 12

ค่า 12 จะถูกนำไปเก็บไว้ในช่องใดช่องหนึ่งในหน่วยความจำ พร้อมกับเก็บค่า memory address ไว้ในสิ่งที่เรียกว่า Reference ของตัวแปร สามารถเรียกดูได้จากค่า &month

ทุกตัวแปรที่เราประกาศ จะมี Reference เสมอ ทริคก็คือ ให้เราใส่ สัญลักษณ์ & ลงไปที่หน้าชื่อเพื่อเรียกดูค่า memory address

ในกรณีที่เราประกาศตัวแปรที่มีขนาดมากกว่า 1 Byte เช่น u32 (4 Byte)ก็ต้องใช้ 4 ช่องในการเก็บ ส่วนค่า reference จะชี้ไปที่ช่องแรกสุดเสมอ

โดยทั่วไป memory address มักถูกแสดงผลอยู่ในรูปแบบเลขฐาน 16 แทนที่จะเป็นเลขฐาน 10 ดังนั้น memory address ช่องที่ 7 จะถูกแทนได้เป็น 0x000000000007 หรือ อีกตัวอย่างคือ memory address ช่องที่ 4,305,955,008 ก็จะถูกแทนได้เป็น 0x00010d0b38d0

0x จะเป็น สัญลักษณ์ไว้บอกว่าเป็นเลขฐาน 16

Virtual Memory

เมื่อเราเห็นภาพของ memory ตรงกันแล้ว เราจะมาทำความรู้จักกับ Virtual Memory กันต่อเลย

ทุกครั้งที่เราทำการรันคำสั่ง cargo runหรือ รัน binary ที่ได้จากการ compile sourcecode ของเรา แทนที่ตัวโปรแกรมจะสามารถเข้าถึงหน่วยความจำอย่าง​ RAM ได้โดยตรง กลับกลายเป็นว่าระบบปฏิบัติการณ์คอมพิวเตอร์(OS) จะไม่อนุญาติให้โปรแกรมของเราเข้าถึง RAM ได้ แต่ต้องผ่านหน่วยความจำเสมือนที่ OS เป็นคนสร้างขึ้นมาแทน เรียกว่า Virtual Memory

ในมุมมองของโปรแกรมเรา จะไม่มีความแตกต่างระหว่าง Virtual Memory กับ หน่วยความจำจริง เพราะตัวโปรแกรมจะเชื่ออย่างสนิทใจว่า Virtual Memory นั้นคือหน่วยความจำจริง โดย OS และ CPU จะทำงานอยู่เบื้องหลัง ในการจับคู่ memory address ใน Virtual memory ที่โปรแกรมใช้งาน กับหน่วยความจำจริงๆใน RAM ให้อีกที

การที่ OS สร้าง Virtual Memory ให้กับโปรแกรม มีข้อดีดังนี้

  1. โปรแกรมสามารถใช้งานหน่วยความจำที่ใหญ่กว่า RAM ที่คอมพิวเตอร์มีได้ เนื่องจาก OS สามารถนำข้อมูลที่ไม่ค่อยได้ใช้ในโปรแกรมเราไปเก็บใน Harddisk ก่อน เพื่อปล่อย memory address จริงใน RAM ให้ว่าง
    สิ่งนี้ทำให้พื้นที่ดูเยอะขึ้นได้ เพราะสามารถใช้งาน memory address ใน RAM ซ้อนกัน จากนั้นเมื่อจะใช้ค่าที่ถูกปล่อยอีกครั้ง OS จึงค่อยสลับค่าเดิมที่เคยอยู่ในพื้นที่กลับมาจากใน harddisk
  2. OS มีอิสระในการจัดการ RAM มากขึ้น เช่น ระหว่างที่โปรแกรมกำลังทำงาน OS สามารถลบข้อมูลขยะ หรือ ย้ายตำแหน่งของข้อมูลไปมา เพื่อเพิ่มประสิทธิภาพของการใช้งานหน่วยความจำโดยรวม โดยไม่กระทบการทำงานของโปรแกรม
  3. ช่วยให้แต่ละโปรแกรมมีความปลอดภัยมากขึ้น เนื่องจากทุกโปรแกรมมีพื้นที่ Virtual memory ของตัวเอง การเข้าถึง memory address ของโปรแกรมอื่นๆจะเป็นไปได้ยากมาก OS จะป้องกันไม่ให้เข้าถึงได้
  4. ประหยัดพื้นที่ RAM มากขึ้น OS สามารถแชร์ Library ที่โปรแกรมหลายๆอันใช้งานซ้ำกันได้ ดังนั้นแทนที่ว่าทุกโปรแกรมจะต้องจองพื้นที่ใน RAM สำหรับ Library นั้น OS ก็จะทำการจองหน่วยความจำใน​ RAM แค่ที่เดียวก็พอ แล้วนำไปใช้ในทุกๆ Virtual Memory ของแต่ละโปรแกรม

ต่อจากนี้เมื่อพูดถึงหน่วยความจำในโปรแกรม ถ้าไม่ได้ระบุโดยตรงว่าเป็นหน่วยความจำจริง (Physical memory) เราจะหมายถึง Virtual Memory เสมอครับ

โครงสร้างของ Virtual Memory

Virtual Memory มีการแบ่งพื้นที่ไว้เป็นส่วนๆ เพื่อจัดระเบียบการเก็บข้อมูล

ในแต่ละส่วนมีหน้าที่เฉพาะและที่มาที่ชัดเจน ตรงบนสุดจะมีพื้นที่เล็กๆไว้ให้โปรแกรมเก็บ Environment Variables และ command-line arguments ต่างๆ ที่ได้ระบุไปตอนสั่งรันโปรแกรม ส่วนนี้จะอ่านค่าได้อย่างเดียว ไม่สามารถแก้ไขได้

ส่วนอื่นๆที่อยู่ด้านล่าง จะเป็นข้อมูลที่มาจากตัว binary file การจะรู้ว่าข้อมูลส่วนนี้เกี่ยวข้องกับอะไร เราต้องมาดู format binary file ที่ได้จากการ compile Rust sourcecode ของเรา

เมื่อทำการ compile source code ด้วย cargo หรือ rustc ใน Linux ตัว binary file ที่ออกมาจะอยู่ในรูปแบบ​​ ELF-64 (Executable file)

binary ELF-64 จะเก็บข้อมูลตามประเภทไว้เป็นส่วนๆเช่นกัน โดยที่ส่วน File header, Program header และ linker metadata เราไม่จำเป็นต้องไปสนใจเนื่องจากเป็นส่วนที่โปรแกรมเราไม่สามารถเข้าถึงได้ ใช้เก็บข้อมูลทั่วๆไปของ binary

สิ่งที่เราต้องให้ความสนใจคือ กลุ่ม Common Segment (ตัวอักษรสีเหลืองในรูป) ซึ่งเป็นส่วนที่เข้าถึงได้จากโปรแกรมของเรา และนำไปใช้ใน Virtual Memory มีดังนี้ (ถ้าคำอธิบายยังไม่ชัดพอ ให้ดูตัวอย่างโค้ดด้านล่างอีกที)

  • bss ใช้เก็บค่าตัวแปร static lifetime ที่ไม่ได้ระบุค่าเริ่มต้น
  • rodata ใช้เก็บค่าต่างๆ ที่เป็น static lifetime เช่น string literal
  • data ใช้เก็บค่าข้อมูลของตัวแปร global mutable ที่เป็น static lifetime ตัวแปรนี้สามารถแก้ไขค่าได้
  • text ใช้เก็บโค้ดที่จะ execute ของเรา ส่วนนี้จะถูกประมวลผลโดย CPU เพื่อให้คอมพิวเตอร์ทำงานตามสิ่งที่เราเขียนโปรแกรม

ดูตัวอย่างโค้ดประกอบได้เลย

กระบวนการย้ายข้อมูลจาก binary file ไปยัง Virtual Memory

หลังจากที่คอมไพล์ได้ไฟล์ binary ELF-64 มาแล้ว เมื่อทำการรันโปรแกรม OS ก็จะสร้าง Virtual Memory มาให้โปรแกรม จากนั้นจึงนำข้อมูลกลุ่ม Common Segment ใน binary file ไปวางไว้ยังตำแหน่งที่อยู่ใน Virtual Memory เริ่มจากตำแหน่ง low-address

ในสถาปัตกรรมคอมพิวเตอร์แบบ 64 bit ของ Linux ตำแหน่ง low address ใน Virtual memory จะเริ่มที่ 0x000000400000 และ high address ที่เป็นเพดานสูงสุดที่โปรแกรมจะใช้งานได้ คือตำแหน่ง 0x7FFFFFFFFFFF ย้ำว่าค่าทั้งสองนี้ไม่ใช่ memory address ใน RAM แต่เป็นใน Virtual memory เท่านั้น!

ค่า high-low address นี้อาจแตกต่างกันไปตามสถาปัตยกรรมคอมพิวเตอร์และ OS ที่ใช้ และถึงแม้ตำแหน่งในข้อมูลใน Virtual Memory จะกว้างมากๆ แต่ OS ก็ไม่ได้ทำการจองหน่วยความจำจริงให้กับโปรแกรมทั้งหมดเลยในตอนแรก

OS จะทำการจองหน่วยความใน RAM และจับคู่กับ Virtual memory ก็ต่อเมื่อโปรแกรมได้ใช้งานหน่วยความจำตำแหน่งนั้นใน Virtual Memory ไปแล้ว

จุดที่น่าสนใจ คือ พื้นที่ตรงกลาง ระหว่าง bss และ Environment Variables / Arguments จะเป็นช่วงของหน่วยความจำที่กว้างมากๆที่ยังไม่ได้ใช้งาน ช่องว่างมโหฬารนี้เป็นจะที่อยู่ของ Stack และ Heap ที่เราจะพูดถึงต่อไป ใน Part 2 นั่นเอง ฝากติดตามกันด้วยนะครับ 🎉

อ้างอิง

--

--

Titiwat Be Munintravong

Robotics and Rust enthusiasm. Full-stack web developer (React / Typescript/ Node).