1 คะแนน โดย GN⁺ 2025-10-26 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • บทวิเคราะห์เชิงเทคนิคที่สำรวจกระบวนการที่ เคอร์เนลสร้างและเริ่มต้นโปรเซสผ่าน 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

1 ความคิดเห็น

 
GN⁺ 2025-10-26
ความคิดเห็นจาก Hacker News
  • อธิบายเกี่ยวกับ กระบวนการ dynamic linking ของไฟล์ ELF
    เคอร์เนลจะทำการแมป PT_LOAD segment ของ ELF แล้วโหลด dynamic linker (ld.so) ที่ระบุไว้ใน PT_INTERP ก่อนส่งต่อการควบคุมให้
    หลังจากนั้น dynamic linker จะทำ relocation ให้ตัวเองและโหลด shared object ที่จำเป็นด้วย mmap/mprotect
    โครงสร้างนี้ถูกเปรียบเทียบว่าคล้ายกับกลไก shebang(#!) ของสคริปต์

    • เคอร์เนลไม่สนใจข้อมูล section เลย และจัดการเฉพาะ PT_LOAD segment เท่านั้น
      มีการแชร์ประสบการณ์ว่าเคยพยายามแทรกไฟล์ตามใจลงใน ELF ด้วย objcopy แต่เคอร์เนลไม่โหลด จนสับสนอยู่พักหนึ่ง
      สุดท้ายเลยสร้าง เครื่องมือแพตช์ program header table ขึ้นมาเอง และบอกว่าฟีเจอร์นี้ถูกเพิ่มเข้าไปใน linker อย่าง mold แล้ว
      บทความที่เกี่ยวข้อง: Self-contained Lone Lisp Applications
    • ผู้เขียนยอมรับว่าเคยแก้ไขเนื้อหาผิดแล้วโพสต์ขึ้นไปก่อนหน้านี้ และบอกว่าจะปรับแก้
    • เคยสงสัยมาตลอดว่าบน Linux ตัว loader ทำงานอยู่ใน user space แล้วทำไมถึงไม่มี loader ที่หลากหลายกว่านี้
  • มีการทดลองแพ็กโค้ดทั้งหมดให้ทำงาน ก่อน main() หรือแม้แต่ไม่มี main() เลย
    บทความที่เกี่ยวข้อง: Packing a codebase into a single function

    • พออ่านแล้วรู้สึกว่าน่าสนใจ เพราะมันเรียบง่ายกว่าที่คิดและไม่ได้เปราะบางอย่างที่คาด
      มีการพูดติดตลกว่าแค่เปลี่ยนทุกฟังก์ชันให้เป็นรูปแบบ main(100+n, ...) ก็พอ
  • ถ้าสนใจหัวข้อนี้ แนะนำให้ดู cpu.land ที่เจ้าตัวทำขึ้น
    เนื้อหาเน้น multitasking และกระบวนการโหลดโค้ด มากกว่าผัง layout ของหน่วยความจำ

    • มีคนมาขอบคุณและบอกว่าชอบ cpu.land มากจริงๆ
  • สงสัยว่ามีโปรเจกต์ C มากแค่ไหนที่หลีกเลี่ยง standard library แล้วเรียก Linux syscall โดยตรงเท่านั้น
    รู้สึกว่าการเขียนโค้ดแบบนี้สนุกกว่ามาก

    • มีคนแย้งว่าการใช้ syscall ตรงๆ กลับไม่มีประสิทธิภาพ
      ฟังก์ชันอย่าง ALSA, DRM และอื่นๆ มีข้อดีหลายอย่างหากเข้าถึงผ่าน system library แทน syscall ของเคอร์เนลโดยตรง
      อธิบายว่าวิธีนี้ดีกว่า แนวทางแบบ Windows ในแง่ของ portability และ maintainability
    • เสริมว่าบน Windows ถ้าใช้แค่ Win32 API ก็ไม่จำเป็นต้องลิงก์ C runtime
    • อีกคนบอกว่าตัวเองเคยสร้างโปรเจกต์ liblinux เพื่อเขียนโปรแกรมด้วย syscall ล้วนมาก่อน
      ตอนนี้เลิกทำไปแล้วเพราะ Linux มี header ของ nolibc ที่ดีมากแล้ว
      แต่ปัจจุบันกำลังพัฒนาภาษา Lisp interpreter ที่อิง syscall อยู่
      และมองว่าการทดลองประกอบ Linux user space ขึ้นมาเองผ่าน system call เป็นการเดินทางที่น่าสนใจมาก
    • บอกว่าพยายามรักษา portability ไว้ แต่ file descriptor นั้นสะดวกมากจนตัดใจยาก
    • เสริมว่าจริงๆ แล้วโค้ดไดรเวอร์จำนวนมากก็ใช้ syscall อย่างเดียว
  • อธิบายว่า 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 ยิ่งเพิ่ม จะเข้าใจง่ายกว่ามาก

    • มีอีกคนบอกว่าอย่างไรก็ดี สแต็กก็ยัง “โตลงด้านล่าง” ได้ถูกต้องอยู่ เพราะ stack pointer ลดลง เมื่อมันขยายตัว
      แต่เสนอว่าถ้าจะทำภาพประกอบ ควรทำในแนวนอนจะเป็นธรรมชาติกว่า
    • อีกคนเล่าว่าตัวเองก็เคยสับสนแบบเดียวกันมาก่อน และเคยงงกับ การเขียน address แบบ little-endian ด้วย
    • อีกความเห็นโต้แย้งว่าถ้ามองจากทิศทางการกองซ้อนของสิ่งของจริงๆ คำว่า “stack โตลงด้านล่าง” ก็ไม่ได้ชวนให้เข้าใจได้โดยสัญชาตญาณนัก
  • มีคนบอกว่าชอบทำการทดลองแบบนี้กับ ไมโครคอนโทรลเลอร์ PIC16 รุ่นเก่า
    เพราะสนุกกับการได้ควบคุม stack pointer, timer และการตั้งค่าตัวแปรต่างๆ ด้วยตัวเอง

  • มีการแชร์ประสบการณ์เกี่ยวกับ shebang(#!)
    แอปพลิเคชัน Java แจ้งข้อผิดพลาดว่าหา execution script ไม่พบ
    แต่ปัญหาจริงคือพาธ shebang ของสคริปต์ไม่ถูกต้อง
    บนเครื่อง local รันได้ปกติ แต่พอไปอยู่บน remote server กลับมีปัญหาเพราะพาธของ interpreter ต่างกัน

    • เสริมว่านี่ไม่ใช่ปัญหาเฉพาะของ Java แต่เกิดได้กับทุกโปรแกรมที่เจอข้อผิดพลาด ENOENT
      และแนะนำว่าเมื่อรันด้วย strace จะเห็นได้ทันทีว่า syscall ไหนเป็นจุดที่เกิดข้อผิดพลาด
    • มีการแชร์บทความวิเคราะห์โครงสร้างของ shebang: What the #! means
    • เสริมว่าถ้าต้องการให้เคอร์เนลรองรับ shebang ต้องเปิดการตั้งค่า CONFIG_BINFMT_SCRIPT=y
  • มีคนบอกว่าระหว่างดีบักมักสับสนเสมอว่า ลำดับการ relocation ของ main binary ถูกนำมาใช้เมื่อไร
    ไม่แน่ใจว่าเกิดก่อนหรือตอนหลังจากที่ linker แก้ symbol ของตัวเอง และบอกว่ามันดูเหมือน black magic มาก

  • มีคนชี้ว่าใน Markdown ส่วน “lang_start function (defined here)” ลิงก์เสียอยู่