• บทวิเคราะห์เชิงเทคนิคที่สำรวจกระบวนการที่ เคอร์เนลสร้างและเริ่มต้นโปรเซสผ่าน system call execve ก่อนที่โปรแกรมจะเริ่มทำงาน
  • การเรียกนี้จะส่งพาธของไฟล์ปฏิบัติการ อาร์กิวเมนต์ และตัวแปรสภาพแวดล้อม และเคอร์เนลจะใช้ข้อมูลเหล่านี้เพื่อ โหลดไฟล์ปฏิบัติการในรูปแบบ ELF
  • ไฟล์ ELF มีโค้ด ข้อมูล สัญลักษณ์ และข้อมูลการลิงก์แบบไดนามิก โดยเคอร์เนลจะตีความสิ่งเหล่านี้เพื่อทำ การแมปหน่วยความจำและการเริ่มต้นสแตก
  • หลังจากนั้นเคอร์เนลจะส่งการควบคุมไปยัง entrypoint _start และกว่าจะถึงการเรียก ฟังก์ชัน main ที่ผู้ใช้กำหนดเอง ก็ต้องผ่านการเริ่มต้น runtime ของแต่ละภาษาเสียก่อน
  • กระบวนการนี้แสดงให้เห็นถึง โครงสร้างการทำงานร่วมกันของระบบปฏิบัติการ คอมไพเลอร์ และ runtime และมีความสำคัญต่อการทำความเข้าใจว่าการรันโปรแกรมเกิดขึ้นอย่างไรในระดับระบบ

จุดเริ่มต้นของการรันโปรแกรม: การเรียก execve

  • ใน Linux การรันโปรแกรมเริ่มต้นผ่าน system call execve
    • อยู่ในรูป execve(const char *filename, char *const argv[], char *const envp[]) โดยส่งชื่อไฟล์ปฏิบัติการ รายการอาร์กิวเมนต์ และรายการตัวแปรสภาพแวดล้อม
    • เคอร์เนลใช้ข้อมูลนี้เพื่อตัดสินใจว่าจะรันโปรแกรมใดในสภาพแวดล้อมแบบไหน
  • ในภาษาระดับสูง การเรียกนี้มักถูก ครอบด้วย API สำหรับรันโปรเซสของ standard library
    • ตัวอย่าง: std::process::Command ของ Rust จะเรียก execve ภายใน
    • และจะมีกระบวนการแปลงชื่อคำสั่งให้เป็นพาธแบบเต็ม คล้ายกับการค้นหา PATH ของเชลล์
  • ในกรณีของสคริปต์ที่มี Shebang(#!) เคอร์เนลจะใช้ interpreter ที่ระบุไว้เพื่อรันโปรแกรม
    • ตัวอย่าง: #!/usr/bin/python3 → รันด้วย Python interpreter

ELF: โครงสร้างของไฟล์ปฏิบัติการ

  • ไฟล์ปฏิบัติการบน Linux ใช้รูปแบบ ELF(Executable and Linkable Format)
    • ELF คือ ฟอร์แมตไฟล์ปฏิบัติการมาตรฐาน ที่บรรจุโค้ด ข้อมูล สัญลักษณ์ และข้อมูลการ relocation
    • ระบบปฏิบัติการอื่นใช้ฟอร์แมตต่างออกไป เช่น Mach-O(macOS) และ PE(Windows)
  • ส่วนหัวของ ELF จะมีข้อมูลเกี่ยวกับโครงสร้างไฟล์และการจัดวางในหน่วยความจำ
    • ตัวอย่างรายการ: ELF Magic, Class, Entry point address, Program headers, Section headers
    • Entry point address คือที่อยู่ของคำสั่งแรกที่โปรแกรมจะเริ่มทำงาน
  • ในตัวอย่าง ELF header ระบุว่าเป็น ไฟล์ปฏิบัติการ ELF32 สำหรับสถาปัตยกรรม RISC-V โดยกำหนดที่อยู่ 0x10358 เป็น entrypoint

องค์ประกอบภายใน ELF

  • ไฟล์ ELF ประกอบด้วย section หลายส่วน
    • .text: โค้ดที่สามารถรันได้
    • .data: ตัวแปรโกลบอลที่ถูกกำหนดค่าเริ่มต้นแล้ว
    • .bss: ตัวแปรโกลบอลที่ยังไม่ได้กำหนดค่าเริ่มต้น
    • .plt: ตารางสำหรับเรียก shared library
    • .symtab, .strtab: ตารางสัญลักษณ์และสตริง
  • PLT(Procedure Linkage Table) รองรับการเรียกฟังก์ชันจาก shared library
    • ตัวอย่าง: printf, malloc ของ libc
    • section PT_INTERP ของ ELF ใช้ระบุ dynamic linker(interpreter)
  • เคอร์เนลจะอ่าน ELF แล้ว นำ section ที่โหลดได้ไปวางในหน่วยความจำ และหากจำเป็นก็จะใช้ฟีเจอร์ความปลอดภัยอย่าง ASLR, NX bit

ตารางสัญลักษณ์และการลิงก์ตอนรันไทม์

  • ตารางสัญลักษณ์(symtab) ของ ELF มีข้อมูลที่อยู่ของฟังก์ชันและตัวแปร
    • ตัวอย่าง: มี entry อย่าง _start, main, __libc_start_main
    • แม้แต่โปรแกรม “Hello, World!” แบบง่าย ๆ ก็อาจมีสัญลักษณ์มากกว่า 2300 รายการ
  • ส่วนใหญ่เกิดจาก standard library และโค้ดเริ่มต้น runtime
    • เพราะมีการลิงก์เข้ากับ implementation ของ libc เช่น musl หรือ glibc
  • หลังจากเคอร์เนลโหลดแต่ละ section ของ ELF แล้ว ก็จะส่งการควบคุมต่อไปยัง interpreter (dynamic linker)
    • interpreter จะจัดการ relocation, address randomization(ASLR), การตั้งค่าสิทธิ์การรัน(NX bit) เป็นต้น

กระบวนการเริ่มต้นสแตก

  • ก่อนรันโปรแกรม เคอร์เนลต้องสร้าง สแตก(stack) ขึ้นมาโดยตรง
    • สแตกถูกใช้สำหรับตัวแปรภายใน ฟรেমการเรียกฟังก์ชัน และการส่งอาร์กิวเมนต์
  • argv, envp ที่ส่งมากับการเรียก execve จะถูกเก็บไว้บนสแตก
    • โปรแกรมจะใช้สิ่งนี้เพื่อเข้าถึงอาร์กิวเมนต์บรรทัดคำสั่งและตัวแปรสภาพแวดล้อม
  • เคอร์เนลยังใส่ ELF auxiliary vector(auxv) ไว้บนสแตกด้วย
    • มีรายการมากกว่า 30 รายการ เช่น ขนาดเพจ ข้อมูลเมทาดาทา ELF และข้อมูลระบบ
    • ตัวอย่าง: AT_PAGESZ ใช้ระบุขนาดของ memory page (เช่น 4KiB)
  • ในตัวอย่าง RISC-V emulator ตัวชี้สแตก(sp) จะเริ่มจากที่อยู่สูง แล้ววางอาร์กิวเมนต์ ตัวแปรสภาพแวดล้อม และ auxiliary vector ลงมาในลำดับย้อนกลับ

entrypoint และฟังก์ชัน _start

  • entrypoint ของ ELF ถูกกำหนดให้เป็นที่อยู่ของฟังก์ชัน _start
    • _start คือโค้ดใน user space ตัวแรกที่เคอร์เนลส่งการควบคุมมาให้
  • ภาษาส่วนใหญ่จะทำ การเริ่มต้น runtime ที่ _start ก่อน แล้วจึงค่อยเรียก main
    • ตัวอย่าง: std::rt::lang_start ของ Rust, __libc_start_main ของ C
  • ในตัวอย่าง Rust สามารถใช้แอตทริบิวต์ #![no_std], #![no_main] เพื่อกำหนด _start เองได้โดยไม่ใช้ runtime
    • ภายใน _start จะอ่าน argc, argv, envp จากสแตก แล้วเรียก pointer ของ main
  • runtime ของแต่ละภาษาจะทำงานเริ่มต้นเฉพาะทางภาษา เช่น global constructor, thread-local storage, การจัดการข้อยกเว้น

ลำดับการทำงานทั้งหมดก่อนถึงการเรียก main()

  • กระบวนการทั้งหมดสรุปได้ดังนี้
    1. เรียก execve → เคอร์เนลโหลดไฟล์ ELF
    2. ตีความ ELF → แมป section ของโค้ด/ข้อมูล และระบุ interpreter
    3. สร้างสแตก → เก็บอาร์กิวเมนต์ ตัวแปรสภาพแวดล้อม และ auxiliary vector
    4. รัน entrypoint _start
    5. หลังเริ่มต้น runtime แล้วจึงเรียก main()
  • ลำดับทั้งหมดนี้แสดงให้เห็นถึง โครงสร้างการทำงานร่วมกันของเคอร์เนลระบบปฏิบัติการ ฟอร์แมต ELF และ runtime ของภาษา
  • แม้ Linux kernel จริงจะมีลอจิกภายในเพิ่มเติม เช่น address space, process table, การจัดการกลุ่ม แต่บทความนี้อธิบายเฉพาะแกนหลักของลำดับก่อนหน้านั้น

บทสรุปและการแก้ไขข้อมูล

  • กระบวนการก่อน main() คือ การผสานกันระหว่างการเริ่มต้นระดับเคอร์เนลและการตั้งค่า runtime
  • แม้แต่โปรแกรม “Hello, World!” ธรรมดาก็ยังต้องผ่านโครงสร้าง ELF ที่ซับซ้อนและการเริ่มต้น runtime ก่อนจะรันได้
  • ในเวอร์ชันแรกของบทความ มีการอธิบายว่าลอจิกการโหลดบาง section เป็นหน้าที่ของเคอร์เนล แต่ภายหลังได้แก้ไขว่าแท้จริงแล้วเป็น บทบาทของ ELF interpreter
  • บทวิเคราะห์นี้เป็นพื้นฐานที่มีประโยชน์ต่อการทำความเข้าใจ system programming, คอมไพเลอร์ และสถาปัตยกรรมของ OS

ยังไม่มีความคิดเห็น

ยังไม่มีความคิดเห็น