• สำรวจโลกของนามธรรมที่ซ่อนอยู่เบื้องหลังโปรแกรม Hello World สมัยใหม่

    • บทความนี้กล่าวถึงโปรแกรม Hello World ที่เขียนด้วย C โดย C ถือเป็นภาษาระดับสูงที่ยังใกล้กับระบบมากที่สุดในบรรดาภาษาที่ไม่ต้องกังวลว่า ภาษานั้นทำอะไรอยู่ก่อนที่โปรแกรมจะทำงานจริง ไม่ว่าจะเป็นผ่าน interpreter/compiler/JIT
    • เดิมทีตั้งใจเขียนให้ใครก็ตามที่มีพื้นฐานการเขียนโค้ดพอจะเข้าใจได้ แต่ถ้ามีความรู้เรื่อง C หรือแอสเซมบลีอยู่บ้างก็น่าจะช่วยได้
  • เริ่มต้นกับโปรแกรม Hello World

    • ทุกคนน่าจะคุ้นเคยกับโปรแกรม Hello World กันดี ใน Python โปรแกรมแรกที่หลายคนน่าจะเคยเขียนอาจเป็น print('Hello World!')
    • ในบทความนี้จะพาไปดู Hello World ที่เขียนด้วยภาษา C โดยใน C นั้นไม่สามารถเรียก interpreter เพื่อรันโปรแกรมได้ ต้องรันคอมไพเลอร์ก่อนเพื่อแปลงเป็นโค้ดภาษาเครื่องที่ตัวประมวลผลของคอมพิวเตอร์สามารถรันได้โดยตรง
  • วิเคราะห์โปรแกรมของเรา

    • เมื่อลองวิเคราะห์ไฟล์โปรแกรมที่คอมไพล์แล้ว จะพบว่าเป็นไฟล์ปฏิบัติการ ELF และสำหรับสถาปัตยกรรมชุดคำสั่ง x86-64
    • ไฟล์ปฏิบัติการ ELF บนลินุกซ์ก็เทียบได้กับไฟล์ .exe บนวินโดวส์
    • x86-64 คือสถาปัตยกรรม CPU ที่ใช้บนพีซีมาตั้งแต่ IBM PC เปิดตัวในปี 1981
    • ไฟล์นี้มีโค้ดภาษาเครื่อง ซึ่งเป็นภาษาเดียวที่ CPU เข้าใจได้
  • วิเคราะห์โค้ดแอสเซมบลี

    • ลองหา entry point ซึ่งเป็นตำแหน่งเริ่มต้นของโปรแกรม แล้ววิเคราะห์โค้ดแอสเซมบลี
    • ภาษาแอสเซมบลีคือรูปแบบที่มนุษย์อ่านได้ของโค้ดภาษาเครื่อง
    • จะเห็นโค้ดเริ่มต้นที่คอมไพเลอร์ (ให้แม่นยำกว่านั้นคือ linker) เพิ่มเข้ามาโดยอัตโนมัติ และเห็นว่ามีการเรียกฟังก์ชัน __libc_start_main
    • แต่โค้ดนี้ไม่ได้ถูกนิยามไว้ในโปรแกรมของเรา มันอยู่ที่อื่น
  • ไลบรารีมาตรฐานของ C

    • ฟังก์ชัน __libc_start_main ถูกนิยามอยู่ใน libc.so.6 ซึ่งเป็นไลบรารีมาตรฐาน C ของระบบเรา
    • ไลบรารีมาตรฐาน C คือชุดของรูทีนและฟังก์ชันที่แทบทุกโปรแกรมบนคอมพิวเตอร์ใช้ร่วมกัน
    • ไลบรารี C จะทำงานเตรียมความพร้อม แล้วจึงเรียกฟังก์ชัน main() ที่เราเขียนไว้ เมื่อ main() คืนค่ากลับมา โปรแกรมก็จะจบการทำงานด้วยรหัสออกจากโปรแกรมที่เราส่งคืนไว้
  • วิเคราะห์ฟังก์ชัน main()

    • ในฟังก์ชัน main() จะมีการตั้งค่า stack frame กำหนดที่อยู่ของสตริง Hello World เป็นอาร์กิวเมนต์ของการเรียกฟังก์ชัน แล้วเรียก puts()
    • puts() นี้เดิมมาจากการเรียก printf() แต่คอมไพเลอร์ทำ optimization แล้วเปลี่ยนให้ เพราะ printf() ซับซ้อนกว่า ขณะที่ puts() มีหน้าที่เพียงพิมพ์สตริงที่ไม่มีรูปแบบ
  • สตริง Hello World

    • สตริงอยู่ในรูปแบบ "Hello World!" ตามด้วยตัวจบสตริงแบบ NULL
    • ในภาษา C ไม่มีข้อมูลความยาวของสตริงติดมาด้วย จึงใช้ตัวจบแบบ NULL เพื่อบอกจุดสิ้นสุดของสตริง ถ้าไม่มีตัวจบแบบ NULL โปรแกรมอาจอ่านหน่วยความจำที่ไม่ควรอ่านไปเรื่อย ๆ จนตายด้วย Segmentation Fault
    • เนื่องจากการ optimization ของคอมไพเลอร์ อักขระขึ้นบรรทัดใหม่ (\n) ที่ใช้ใน printf() จึงถูกตัดออก เพราะ puts() จะเติมขึ้นบรรทัดใหม่ให้เองหลังพิมพ์สตริง
  • ฟังก์ชัน puts()

    • ฟังก์ชัน puts() จะไปเรียกโค้ดในไลบรารีมาตรฐานต่ออีกทอดหนึ่ง
    • ถ้าดูโค้ดของ glibc จะเห็นการเรียกต่อกันเป็น _IO_puts -> _IO_new_file_xsputn แต่โค้ดค่อนข้างซับซ้อนจึงอธิบายได้ยาก
    • ในกรณีของ musl libc จะง่ายกว่า โดยเรียกเป็น puts -> fputs -> fwrite -> __fwritex -> __stdio_write -> syscall
  • system call

    • ไม่ว่าไลบรารี C จะใหญ่แค่ไหน มันก็ไม่สามารถสื่อสารกับฮาร์ดแวร์โดยตรงได้ สิ่งนั้นมีแต่เคอร์เนลเท่านั้นที่ทำได้
    • ดังนั้นการเรียก puts() จึงลงท้ายด้วยการขอให้ OS ช่วยทำบางอย่าง ซึ่งในที่นี้คือเขียนสตริงลงในสตรีมเอาต์พุต
    • musl libc ใช้ system call ชื่อ writev ซึ่งช่วยให้เขียนหลายบัฟเฟอร์ได้ในครั้งเดียว
    • system call เกิดขึ้นโดยตั้งค่าพารามิเตอร์ลงในรีจิสเตอร์แล้วรันคำสั่ง syscall จากนั้นการควบคุมจะถูกส่งต่อไปยังเคอร์เนล ซึ่งจะอ่านพารามิเตอร์และดำเนินการ system call ให้
  • เคอร์เนล

    • เคอร์เนลลินุกซ์ต้องทำงานตามที่ร้องขอผ่าน system call โดย system call write จะสั่งให้เคอร์เนลเขียนลงไฟล์หรือสตรีมที่เปิดอยู่ในระบบไฟล์
    • write รับพารามิเตอร์ 3 ตัว ได้แก่ file descriptor ที่จะเขียน บัฟเฟอร์ที่จะเขียน และจำนวนไบต์ที่จะเขียน
    • ส่วนจะเขียนไปที่ไหนจริง ๆ นั้นขึ้นอยู่กับสถานการณ์ เช่น ถ้าเป็น terminal emulator ก็จะมองเห็นเป็น virtual terminal (pty) ถ้าเป็นการล็อกอินระยะไกลก็จะถูกส่งต่อไปยัง sshd ถ้าเป็นเทอร์มินัลจริงก็จะไปยัง serial-USB adapter และถ้าเป็น frame buffer console เคอร์เนลจะเรนเดอร์ข้อความแล้วแสดงผลบนจอ
  • บทสรุป

    • ระบบซอฟต์แวร์สมัยใหม่ทำงานอย่างซับซ้อนและประณีตมากบนฮาร์ดแวร์ จนการพยายามทำความเข้าใจให้ครบถ้วนว่าคอมพิวเตอร์ทำงานเล็ก ๆ อย่างหนึ่งได้อย่างไรอาจไม่มีวันสิ้นสุด
    • เพื่อจะอธิบายทั้งหมดได้ จึงจำเป็นต้องละรายละเอียดหลายส่วนออกไป
    • การส่งข้อความ Hello World เป็นเพียงหนึ่งในบรรดา system call และโปรแกรมจำนวนมหาศาลที่กำลังทำงานอยู่บนคอมพิวเตอร์ตอนนี้

ความเห็นของ GN⁺

  • เป็นบทความที่แสดงให้เห็นว่าแต่ละชั้นของระบบคอมพิวเตอร์ใช้ abstraction เพื่อซ่อนความซับซ้อนของชั้นล่าง ทำให้นักพัฒนาสร้างแอปพลิเคชันได้สะดวกขึ้น
  • ในอีกด้านหนึ่งก็ทำให้ตระหนักว่า กว่าที่โค้ดเพียงหนึ่งบรรทัดในแอปจะถูกรันได้จริง มีเรื่องมากมายเกิดขึ้นอยู่ข้างใต้ และยังช่วยให้เข้าใจด้วยว่าทำไมการดีบักจึงยาก
  • ผมคิดว่าโปรแกรมเมอร์ทุกคนควรเข้าใจอย่างดีอย่างน้อยถึงระดับระบบที่อยู่ใต้ภาษาที่ตัวเองใช้เป็นหลัก ไม่จำเป็นต้องรู้ทั้งหมด แต่การรู้ว่าส่วนที่ถูก abstraction ไว้นั้นทำงานจริงอย่างไรเป็นเรื่องสำคัญ
  • ถึงจะใช้ภาษาระดับสูง การศึกษาคอนเซปต์ของ system programming เช่น โครงสร้างหน่วยความจำ, stack และ heap, system call ก็ยังช่วยเรื่องการดีบักและการปรับแต่งประสิทธิภาพได้มาก
  • นักพัฒนาแอปพลิเคชันอาจแทบไม่มีโอกาสไปแตะคอมไพเลอร์หรือไลบรารี C โดยตรง แต่การเข้าใจว่าโปรแกรมที่เราเขียนสุดท้ายแล้วใช้งานระบบอย่างไร เป็นสิ่งจำเป็นในการเป็นโปรแกรมเมอร์ที่ดี

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

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