เส้นทางก่อนที่ฟังก์ชัน main() จะเริ่มทำงาน
(amit.prasad.me)- บทวิเคราะห์เชิงเทคนิคที่สำรวจกระบวนการที่ เคอร์เนลสร้างและเริ่มต้นโปรเซสผ่าน 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 รายการ
- ตัวอย่าง: มี entry อย่าง
- ส่วนใหญ่เกิดจาก standard library และโค้ดเริ่มต้น runtime
- เพราะมีการลิงก์เข้ากับ implementation ของ
libcเช่นmuslหรือglibc
- เพราะมีการลิงก์เข้ากับ implementation ของ
- หลังจากเคอร์เนลโหลดแต่ละ 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
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
อธิบายเกี่ยวกับ กระบวนการ dynamic linking ของไฟล์ ELF
เคอร์เนลจะทำการแมป PT_LOAD segment ของ ELF แล้วโหลด dynamic linker (ld.so) ที่ระบุไว้ใน PT_INTERP ก่อนส่งต่อการควบคุมให้
หลังจากนั้น dynamic linker จะทำ relocation ให้ตัวเองและโหลด shared object ที่จำเป็นด้วย mmap/mprotect
โครงสร้างนี้ถูกเปรียบเทียบว่าคล้ายกับกลไก shebang(#!) ของสคริปต์
มีการแชร์ประสบการณ์ว่าเคยพยายามแทรกไฟล์ตามใจลงใน ELF ด้วย objcopy แต่เคอร์เนลไม่โหลด จนสับสนอยู่พักหนึ่ง
สุดท้ายเลยสร้าง เครื่องมือแพตช์ program header table ขึ้นมาเอง และบอกว่าฟีเจอร์นี้ถูกเพิ่มเข้าไปใน linker อย่าง mold แล้ว
บทความที่เกี่ยวข้อง: Self-contained Lone Lisp Applications
มีการทดลองแพ็กโค้ดทั้งหมดให้ทำงาน ก่อน main() หรือแม้แต่ไม่มี main() เลย
บทความที่เกี่ยวข้อง: Packing a codebase into a single function
มีการพูดติดตลกว่าแค่เปลี่ยนทุกฟังก์ชันให้เป็นรูปแบบ main(100+n, ...) ก็พอ
ถ้าสนใจหัวข้อนี้ แนะนำให้ดู cpu.land ที่เจ้าตัวทำขึ้น
เนื้อหาเน้น multitasking และกระบวนการโหลดโค้ด มากกว่าผัง layout ของหน่วยความจำ
สงสัยว่ามีโปรเจกต์ C มากแค่ไหนที่หลีกเลี่ยง standard library แล้วเรียก Linux syscall โดยตรงเท่านั้น
รู้สึกว่าการเขียนโค้ดแบบนี้สนุกกว่ามาก
ฟังก์ชันอย่าง ALSA, DRM และอื่นๆ มีข้อดีหลายอย่างหากเข้าถึงผ่าน system library แทน syscall ของเคอร์เนลโดยตรง
อธิบายว่าวิธีนี้ดีกว่า แนวทางแบบ Windows ในแง่ของ portability และ maintainability
ตอนนี้เลิกทำไปแล้วเพราะ Linux มี header ของ nolibc ที่ดีมากแล้ว
แต่ปัจจุบันกำลังพัฒนาภาษา Lisp interpreter ที่อิง syscall อยู่
และมองว่าการทดลองประกอบ Linux user space ขึ้นมาเองผ่าน system call เป็นการเดินทางที่น่าสนใจมาก
อธิบายว่า ELF interpreter (ld.so) เป็นผู้รับผิดชอบการโหลดทั้งหมดหลังจากที่ ELF segment เริ่มต้นถูกแมปแล้ว
execveจะทำการแมป PT_LOAD segment และเติม aux vector ลงบนสแต็กจากนั้นจึงกระโดดไปยัง entry point ของ ELF interpreter
เคอร์เนลไม่รู้อะไรเลยเกี่ยวกับ PLT/GOT
คนที่สอนหัวข้อนี้ในมหาวิทยาลัยบอกว่านักศึกษามักสับสนเพราะ memory diagram
ในตำรามักวาดให้ยิ่ง address สูงยิ่งอยู่ด้านบน แต่ใน Linux process จริง
กลับแสดงผลเป็น address ต่ำอยู่ข้างบน และ address สูงอยู่ข้างล่าง
ถ้าดู
/proc/<pid>/mapsจะเห็นว่ายิ่งเลื่อนลง address ก็ยิ่งมากขึ้นนั่นหมายความว่าคำอธิบายอย่าง “heap โตขึ้นด้านบน ส่วน stack โตลงด้านล่าง” เป็นเพียงทิศทางในเชิงตัวเลขเท่านั้น
ในเชิงภาพกลับตรงกันข้าม
มีการเสนอว่าถ้าวาดให้เหมือน IDE ที่ยิ่งลงล่าง address ยิ่งเพิ่ม จะเข้าใจง่ายกว่ามาก
แต่เสนอว่าถ้าจะทำภาพประกอบ ควรทำในแนวนอนจะเป็นธรรมชาติกว่า
มีคนบอกว่าชอบทำการทดลองแบบนี้กับ ไมโครคอนโทรลเลอร์ PIC16 รุ่นเก่า
เพราะสนุกกับการได้ควบคุม stack pointer, timer และการตั้งค่าตัวแปรต่างๆ ด้วยตัวเอง
มีการแชร์ประสบการณ์เกี่ยวกับ shebang(#!)
แอปพลิเคชัน Java แจ้งข้อผิดพลาดว่าหา execution script ไม่พบ
แต่ปัญหาจริงคือพาธ shebang ของสคริปต์ไม่ถูกต้อง
บนเครื่อง local รันได้ปกติ แต่พอไปอยู่บน remote server กลับมีปัญหาเพราะพาธของ interpreter ต่างกัน
และแนะนำว่าเมื่อรันด้วย strace จะเห็นได้ทันทีว่า syscall ไหนเป็นจุดที่เกิดข้อผิดพลาด
มีคนบอกว่าระหว่างดีบักมักสับสนเสมอว่า ลำดับการ relocation ของ main binary ถูกนำมาใช้เมื่อไร
ไม่แน่ใจว่าเกิดก่อนหรือตอนหลังจากที่ linker แก้ symbol ของตัวเอง และบอกว่ามันดูเหมือน black magic มาก
มีคนชี้ว่าใน Markdown ส่วน “lang_start function (defined here)” ลิงก์เสียอยู่