- บทวิเคราะห์เชิงเทคนิคที่สำรวจกระบวนการที่ เคอร์เนลสร้างและเริ่มต้นโปรเซสผ่าน 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()
- กระบวนการทั้งหมดสรุปได้ดังนี้
- เรียก
execve → เคอร์เนลโหลดไฟล์ ELF
- ตีความ ELF → แมป section ของโค้ด/ข้อมูล และระบุ interpreter
- สร้างสแตก → เก็บอาร์กิวเมนต์ ตัวแปรสภาพแวดล้อม และ auxiliary vector
- รัน entrypoint
_start
- หลังเริ่มต้น runtime แล้วจึงเรียก
main()
- ลำดับทั้งหมดนี้แสดงให้เห็นถึง โครงสร้างการทำงานร่วมกันของเคอร์เนลระบบปฏิบัติการ ฟอร์แมต ELF และ runtime ของภาษา
- แม้ Linux kernel จริงจะมีลอจิกภายในเพิ่มเติม เช่น address space, process table, การจัดการกลุ่ม แต่บทความนี้อธิบายเฉพาะแกนหลักของลำดับก่อนหน้านั้น
บทสรุปและการแก้ไขข้อมูล
- กระบวนการก่อน
main() คือ การผสานกันระหว่างการเริ่มต้นระดับเคอร์เนลและการตั้งค่า runtime
- แม้แต่โปรแกรม “Hello, World!” ธรรมดาก็ยังต้องผ่านโครงสร้าง ELF ที่ซับซ้อนและการเริ่มต้น runtime ก่อนจะรันได้
- ในเวอร์ชันแรกของบทความ มีการอธิบายว่าลอจิกการโหลดบาง section เป็นหน้าที่ของเคอร์เนล แต่ภายหลังได้แก้ไขว่าแท้จริงแล้วเป็น บทบาทของ ELF interpreter
- บทวิเคราะห์นี้เป็นพื้นฐานที่มีประโยชน์ต่อการทำความเข้าใจ system programming, คอมไพเลอร์ และสถาปัตยกรรมของ OS
ยังไม่มีความคิดเห็น