คำทักทาย Hello World
(thecoder08.github.io)-
สำรวจโลกของนามธรรมที่ซ่อนอยู่เบื้องหลังโปรแกรม Hello World สมัยใหม่
- บทความนี้กล่าวถึงโปรแกรม Hello World ที่เขียนด้วย C โดย C ถือเป็นภาษาระดับสูงที่ยังใกล้กับระบบมากที่สุดในบรรดาภาษาที่ไม่ต้องกังวลว่า ภาษานั้นทำอะไรอยู่ก่อนที่โปรแกรมจะทำงานจริง ไม่ว่าจะเป็นผ่าน interpreter/compiler/JIT
- เดิมทีตั้งใจเขียนให้ใครก็ตามที่มีพื้นฐานการเขียนโค้ดพอจะเข้าใจได้ แต่ถ้ามีความรู้เรื่อง C หรือแอสเซมบลีอยู่บ้างก็น่าจะช่วยได้
-
เริ่มต้นกับโปรแกรม Hello World
- ทุกคนน่าจะคุ้นเคยกับโปรแกรม Hello World กันดี ใน Python โปรแกรมแรกที่หลายคนน่าจะเคยเขียนอาจเป็น
print('Hello World!') - ในบทความนี้จะพาไปดู Hello World ที่เขียนด้วยภาษา C โดยใน C นั้นไม่สามารถเรียก interpreter เพื่อรันโปรแกรมได้ ต้องรันคอมไพเลอร์ก่อนเพื่อแปลงเป็นโค้ดภาษาเครื่องที่ตัวประมวลผลของคอมพิวเตอร์สามารถรันได้โดยตรง
- ทุกคนน่าจะคุ้นเคยกับโปรแกรม Hello World กันดี ใน Python โปรแกรมแรกที่หลายคนน่าจะเคยเขียนอาจเป็น
-
วิเคราะห์โปรแกรมของเรา
- เมื่อลองวิเคราะห์ไฟล์โปรแกรมที่คอมไพล์แล้ว จะพบว่าเป็นไฟล์ปฏิบัติการ 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()มีหน้าที่เพียงพิมพ์สตริงที่ไม่มีรูปแบบ
- ในฟังก์ชัน main() จะมีการตั้งค่า stack frame กำหนดที่อยู่ของสตริง Hello World เป็นอาร์กิวเมนต์ของการเรียกฟังก์ชัน แล้วเรียก
-
สตริง 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 เคอร์เนลจะเรนเดอร์ข้อความแล้วแสดงผลบนจอ
- เคอร์เนลลินุกซ์ต้องทำงานตามที่ร้องขอผ่าน system call โดย system call
-
บทสรุป
- ระบบซอฟต์แวร์สมัยใหม่ทำงานอย่างซับซ้อนและประณีตมากบนฮาร์ดแวร์ จนการพยายามทำความเข้าใจให้ครบถ้วนว่าคอมพิวเตอร์ทำงานเล็ก ๆ อย่างหนึ่งได้อย่างไรอาจไม่มีวันสิ้นสุด
- เพื่อจะอธิบายทั้งหมดได้ จึงจำเป็นต้องละรายละเอียดหลายส่วนออกไป
- การส่งข้อความ Hello World เป็นเพียงหนึ่งในบรรดา system call และโปรแกรมจำนวนมหาศาลที่กำลังทำงานอยู่บนคอมพิวเตอร์ตอนนี้
ความเห็นของ GN⁺
- เป็นบทความที่แสดงให้เห็นว่าแต่ละชั้นของระบบคอมพิวเตอร์ใช้ abstraction เพื่อซ่อนความซับซ้อนของชั้นล่าง ทำให้นักพัฒนาสร้างแอปพลิเคชันได้สะดวกขึ้น
- ในอีกด้านหนึ่งก็ทำให้ตระหนักว่า กว่าที่โค้ดเพียงหนึ่งบรรทัดในแอปจะถูกรันได้จริง มีเรื่องมากมายเกิดขึ้นอยู่ข้างใต้ และยังช่วยให้เข้าใจด้วยว่าทำไมการดีบักจึงยาก
- ผมคิดว่าโปรแกรมเมอร์ทุกคนควรเข้าใจอย่างดีอย่างน้อยถึงระดับระบบที่อยู่ใต้ภาษาที่ตัวเองใช้เป็นหลัก ไม่จำเป็นต้องรู้ทั้งหมด แต่การรู้ว่าส่วนที่ถูก abstraction ไว้นั้นทำงานจริงอย่างไรเป็นเรื่องสำคัญ
- ถึงจะใช้ภาษาระดับสูง การศึกษาคอนเซปต์ของ system programming เช่น โครงสร้างหน่วยความจำ, stack และ heap, system call ก็ยังช่วยเรื่องการดีบักและการปรับแต่งประสิทธิภาพได้มาก
- นักพัฒนาแอปพลิเคชันอาจแทบไม่มีโอกาสไปแตะคอมไพเลอร์หรือไลบรารี C โดยตรง แต่การเข้าใจว่าโปรแกรมที่เราเขียนสุดท้ายแล้วใช้งานระบบอย่างไร เป็นสิ่งจำเป็นในการเป็นโปรแกรมเมอร์ที่ดี
ยังไม่มีความคิดเห็น