- แม้จะเป็นเพียงบรรทัด Hello World ที่เขียนด้วย C ก็ต้องผ่านไฟล์ปฏิบัติการที่คอมไพล์แล้ว, C standard library, system call, เคอร์เนล และเทอร์มินัลตามลำดับ ก่อนจะแสดงบนหน้าจอ
- ผลลัพธ์ที่สร้างด้วย
gcc hello.c -o hello คือ ไฟล์ปฏิบัติการ ELF 64-bit x86-64 และโค้ด _start จะถูกรันก่อนที่จุดเริ่มต้น 0x1060 ใน ELF header
main() ที่ผู้ใช้เขียนไม่ได้เริ่มทำงานโดยตรง แต่ผ่าน _start และ __libc_start_main ก่อน และ printf("Hello World!\n") ก็ถูกปรับแต่งให้กลายเป็นการเรียก puts() ที่เรียบง่ายกว่า
- สตริงถูกเก็บเป็นลำดับไบต์ไว้ที่
0x2004 ใน .rodata และสตริงของ C ใช้ NULL terminator ในการบอกจุดสิ้นสุดแทนข้อมูลความยาว
- เส้นทางการแสดงผลจริงต่อจากนั้นยังรวมถึง buffering และ locking ของ libc, system call แบบ
write หรือ writev, เคอร์เนล Linux, pseudo-terminal และการเรนเดอร์ของ terminal emulator ซึ่งอาจแตกต่างกันไปตามสภาพแวดล้อมที่รัน
เริ่มจาก C Hello World
- โปรแกรมตัวอย่างคือโค้ดต่อไปนี้ที่เขียนด้วย C
#include <stdio.h>
int main() {
printf("Hello World!\n");
return 0;
}
- แม้จะให้ผลลัพธ์เหมือน
print('Hello World!') ของ Python แต่โปรแกรม C ไม่สามารถรันผ่าน interpreter ได้ทันที และต้อง คอมไพล์ ก่อน
gcc hello.c -o hello
./hello
Hello World!
- หากมีพื้นฐาน C หรือแอสเซมบลีอยู่บ้าง ก็จะตามลำดับการทำงานนี้ได้ไม่ยาก
ตัวตนของไฟล์ปฏิบัติการ
- ประเด็นสำคัญจากผลลัพธ์ของ
file hello คือ ELF executable, x86-64
- ไฟล์ปฏิบัติการ ELF คือรูปแบบของโปรแกรมที่สามารถรันได้บน Linux
x86-64 หมายถึงโปรแกรมภาษาเครื่องสำหรับโปรเซสเซอร์ x86 แบบ 64 บิต
- เมื่อดู ELF header ด้วย
readelf -h hello จะเห็น Entry point address: 0x1060
- ที่อยู่นี้คือจุดที่ CPU เริ่มรันหลังจากโหลดโปรแกรมเสร็จแล้ว
_start และจุดเข้าสู่ C library
- เมื่อทำ disassemble ด้วย
objdump -D hello จะพบ _start อยู่ที่ตำแหน่ง 0x1060
_start ไม่ใช่โค้ดที่ผู้ใช้เขียนเอง แต่เป็นโค้ดที่ compiler หรือให้แม่นยำกว่านั้นคือ linker ใส่เข้ามาให้อัตโนมัติ
- โค้ดนี้จะทำการเริ่มต้นระบบเบื้องต้น แล้วจึงเรียกคำสั่งต่อไปนี้
call *0x2f53(%rip) # 3fd8 <__libc_start_main@GLIBC_2.34>
- ฟังก์ชันนี้ไม่ได้ถูกนิยามไว้โดยตรงในโปรแกรม แต่เป็นส่วนของ standard C library
- ใน dynamic section ของ
readelf -d hello จะเห็น dependency ของ libc.so.6
Shared library: [libc.so.6]
libc.so.6 คือ standard C library ของระบบ และไฟล์ .so บน Linux ก็เก็บโค้ดที่หลายโปรแกรมใช้ร่วมกันได้ คล้ายกับ .dll บน Windows
- C library รับหน้าที่เริ่มต้นระบบ เช่น การจัดการ argument ของ command line และ environment variable จากนั้นจึงเรียก
main() และปิดโปรแกรมด้วยค่าที่ฟังก์ชันนั้นคืนกลับมา
สิ่งที่เกิดขึ้นจริงใน main()
- จากผลการ disassemble จะพบว่า
main() อยู่ที่ 0x1149
- ลำดับการทำงานของ
main() มีดังนี้
- ตั้งค่า stack frame
- เตรียม argument สำหรับการเรียกฟังก์ชัน
- เรียกฟังก์ชันสำหรับพิมพ์ Hello World
- เก็บกวาด stack frame
- คืนค่ารหัสจบการทำงานเป็น
0
- จุดสำคัญคือส่วนที่เตรียมที่อยู่ของสตริงเป็น argument แล้วเรียก
puts@plt
lea 0xeac(%rip),%rax
call 1050 <puts@plt>
- แม้ใน source code จะใช้
printf() แต่ compiler ได้ปรับแต่งให้เป็น puts()
printf() เป็นฟังก์ชันที่ซับซ้อนสำหรับการแสดงผลแบบจัดรูปแบบ
- ตัวอย่างนี้ไม่ได้ใช้ความสามารถอย่างการแทรกตัวแปรลงในรูปแบบข้อความ จึงถูกแทนที่ด้วย
puts() ที่เรียบง่ายกว่า
puts() จะเติมอักขระขึ้นบรรทัดใหม่ต่อท้ายสตริงเอง ดังนั้น \n ในสตริงเดิมจึงถูกเอาออก
วิธีเก็บสตริง
- สตริงอยู่ที่แอดเดรส
0x2004 ในเซกชัน .rodata
- ไบต์ที่ตำแหน่งนั้นมีดังนี้
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
- ลำดับไบต์นี้ตีความได้เป็น
"Hello World!" และ 0x00 ตัวสุดท้าย
0x00 คือ NULL terminator ซึ่งใช้บอกจุดสิ้นสุดของสตริงในภาษา C
- เนื่องจากสตริงของ C ไม่ได้เก็บข้อมูลความยาวไว้ด้วย ฟังก์ชันที่รับสตริงจึงต้องประมวลผลทีละไบต์จนกว่าจะพบ NULL terminator
- หากไม่มี NULL terminator คั่นระหว่างสตริง ฟังก์ชันของ C อาจอ่านต่อไปหลายสตริงติดกัน หรืออ่านหน่วยความจำที่ไม่ควรเข้าถึงจนจบด้วย Segmentation Fault ได้
เส้นทางของ puts() ใน Glibc
puts@plt จะเชื่อมต่อไปยัง standard library ในที่สุด
- ใน Glibc นั้น
puts() จะไปต่อที่ _IO_puts
_IO_puts ทำงานดังนี้
- หาความยาวของสตริง
- ขอ lock สำหรับสตรีมเอาต์พุต
stdout
- ตรวจสอบเงื่อนไขแล้วเรียก
_IO_sputn
- พิมพ์อักขระขึ้นบรรทัดใหม่
- ปลด lock และคืนจำนวนอักขระที่พิมพ์ออกไป
- เนื่องจาก implementation ภายในของ Glibc มีขนาดใหญ่และซับซ้อน บทความจึงเปลี่ยนไปตามลำดับการทำงานของ musl libc ที่เล็กกว่าแทน
กระบวนการที่ผลลัพธ์ไหลลงไปใน musl libc
puts() ของ musl จะขอ lock ของ stdout แล้วเรียก fputs() และ putc_unlocked('\n', stdout) ก่อนจะปลด lock
fputs() จะหาความยาวของสตริงแล้วเรียก fwrite()
- จากนั้น
fwrite() จะขอ lock อีกครั้งและเรียก __fwritex()
__fwritex() จะตรวจสอบสถานะของบัฟเฟอร์ และหากจำเป็นก็จะเรียก function pointer write ของ output stream
stdout ถูกกำหนดให้เป็น fd = 1 และฟังก์ชัน write จะถูกตั้งค่าเริ่มต้นเป็น __stdout_write
__stdout_write() จะทำ TIOCGWINSZ ioctl แล้วจึงเรียก __stdio_write()
__stdio_write() จะทำ system call ผ่าน SYS_writev
system call และเคอร์เนล
- C library เพียงอย่างเดียวไม่สามารถสื่อสารกับฮาร์ดแวร์ได้โดยตรง และการเข้าถึงฮาร์ดแวร์เป็นหน้าที่ของเคอร์เนลระบบปฏิบัติการ
- คำขอให้แสดงผลจะจบลงที่ system call ซึ่งเป็นการร้องขอให้ระบบปฏิบัติการเขียนข้อความลงใน output stream
- โดยทั่วไปการแสดงผลจะทำผ่าน system call
write ส่วน musl ใช้ writev ที่สามารถเขียนหลายบัฟเฟอร์เป็นอาร์เรย์ได้
- implementation ของ system call สำหรับ x86-64 ใน musl แบ่งเป็น
__syscall0 ถึง __syscall6 ตามจำนวน argument
- แต่ละฟังก์ชันจะตั้งค่า argument ลงใน CPU register แล้วรันคำสั่ง
syscall
- การควบคุมจะถูกส่งต่อไปยังเคอร์เนล
- เคอร์เนลจะอ่านพารามิเตอร์จาก register และทำ system call ตามที่ร้องขอ
หลังจากเคอร์เนลจนกระทั่งมองเห็นบนหน้าจอ
- เคอร์เนล Linux รับ system call
write แล้วเขียนข้อมูลลงในไฟล์หรือสตรีมที่เปิดอยู่
- system call
write รับ argument เป็น file descriptor, บัฟเฟอร์ที่จะเขียน และจำนวนไบต์ที่จะเขียน
- ในสภาพแวดล้อมตัวอย่าง โปรแกรม
hello ถูกรันใน GNOME terminal emulator และ stdout เชื่อมต่ออยู่กับ pseudo-terminal /dev/pts/0
- เคอร์เนลจะเก็บข้อความ Hello World ไว้ในบัฟเฟอร์ จากนั้น terminal emulator จะอ่านข้อมูลนั้นแล้วแสดงบนหน้าจอ
- terminal emulator จะเรนเดอร์ข้อความเป็นเฟรม แล้ว X server หรือ compositor จะนำไปผสมกับหน้าจอของแอปอื่น ก่อนให้เคอร์เนลแสดงผลบนดิสเพลย์
- หลังจากนั้นเส้นทางอาจแตกต่างกันไปตามสภาพแวดล้อมการรัน
- ในการล็อกอินระยะไกล เคอร์เนลจะส่งข้อความไปยัง
sshd และ sshd จะห่อเป็นแพ็กเก็ตเข้ารหัสก่อนส่งกลับผ่านเคอร์เนลออกสู่อินเทอร์เน็ต
- หากใช้ physical terminal และ serial-to-USB adapter เคอร์เนลจะส่งข้อความเป็นแพ็กเก็ต USB
- ใน framebuffer console เคอร์เนลจะเรนเดอร์ข้อความเป็นเฟรมแล้วแสดงออกสู่ดิสเพลย์
ความซับซ้อนที่ซ่อนอยู่หลังเอาต์พุตเล็ก ๆ หนึ่งครั้ง
- การส่งข้อความ Hello World เป็นเพียง system call หนึ่งครั้งที่เกิดจากโปรแกรมหนึ่งตัวเท่านั้น
- ซอฟต์แวร์และฮาร์ดแวร์สมัยใหม่ประกอบด้วยชั้นการทำงานที่ซับซ้อนและละเอียดอ่อนมาก จนแม้แต่การทำงานเล็ก ๆ เพียงครั้งเดียวก็ยากจะไล่ตามได้ครบถ้วน
- คำอธิบายนี้ละรายละเอียดปลีกย่อย ข้อยกเว้น และการทำงานภายในของเคอร์เนลไว้อีกมาก และติดตามเฉพาะลำดับหลักเท่านั้น
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ผมลองทำอะไรคล้าย ๆ กันด้วย Rust บน macOS เพราะว่าง ๆ โดยใช้
#![no_std],#![no_main]และเรียก system callWRITE/EXITเองโดยตรง แต่ “Hello, world!” ที่ได้พอเปิดดูด้วย Ghidra แล้ว ไม่ว่าจะทำอย่างไรก็มีขนาดเอาต์พุตอยู่ที่ ประมาณ 16KBน่าจะยังเล่น code golf ลดลงได้อีก แต่ดูมีโอกาสสูงว่ามีคนเคยลองและเขียนเอกสารไว้แล้ว
rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:mainเรียก
ExitProcess,GetStdHandle,WriteFileของkernel32โดยตรง และเพราะเป็น hello world เลยปล่อย panic handler ไว้แบบคร่าว ๆ ในไฟล์ executable ยังมี padding อยู่ค่อนข้างมาก จึงยังใส่อะไรเพิ่มได้โดยไม่ทำให้ขนาดใหญ่ขึ้น และอาจลดได้ด้วยวิธีที่ “อาชญากรรม” กว่านี้ แต่ดูไม่ค่อยมีความหมายเท่าไรอย่างไรก็ดี ฐานข้อมูลดีบัก PDB ที่เกี่ยวข้องมีขนาด 208,896 ไบต์
mainไปเลยแล้วใช้_startและต้องส่ง linker flag เพื่อไม่ให้จัดแนว section ด้วยดู https://darkcoding.net/software/a-very-small-rust-binary-ind... วิธีนี้ทำให้ลงไปใกล้ 500 ไบต์ได้ง่าย ๆ
ถ้าเป็นภาษาที่มี stack สุดท้ายไฟล์ executable ก็น่าจะถูกโหลดขึ้นมาอย่างน้อยราวสอง page คือ page แบบอ่านอย่างเดียว/อ่าน-เขียน
ถ้าจำไม่ผิด เมื่อใช้ optimization ทั้งหมดแล้ว hello world สุดท้ายอยู่ที่ประมาณ 8KB: https://github.com/johnthagen/min-sized-rust
ยังมี rabbit hole อีกเรื่องที่ Musl ข้ามไป บน Linux การเรียกฟังก์ชันระบบไม่ได้มีแค่การใช้
syscallโดยตรงวิธีที่ “สุภาพ” กว่าคือการเรียก vDSO ซึ่งเป็นไลบรารีเล็ก ๆ แบบมหัศจรรย์ที่ kernel map เข้ามาใน address space ให้อัตโนมัติ ทำให้ kernel สามารถจัดเตรียมโค้ดที่เหมาะที่สุดสำหรับการทำ system call ได้
system call บางตัวอาจรันใน user space จนไม่ต้องใช้
syscallเองเลยด้วยซ้ำ และในอดีต vDSO ยังเคยเลือกหนึ่งในกลไกเรียก kernel อย่างint 0x80หรือsysenterให้ด้วยhttps://man7.org/linux/man-pages/man7/vdso.7.html
บน x86-64 วิธีมาตรฐานของ system call คือคำสั่ง
SYSCALLและใน vDSO มีเพียงฟังก์ชันเกี่ยวกับเวลา กับฟังก์ชันเกี่ยวกับ SGX อีกไม่กี่ตัวบทความที่เปรียบเทียบ overhead ของโปรแกรม “Hello World” ในแต่ละภาษาก็น่าอ่าน: https://drewdevault.com/2020/01/04/Slow.html
บทความต่อเนื่อง: https://drewdevault.com/2020/01/08/Re-Slow.html
ยังมีบทความระดับตำนานที่ทำโปรแกรมเล็กที่สุดบน Linux ด้วย โปรแกรมนั้นแค่จบการทำงานด้วย status code 42: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
ในเว็บเดียวกันยังหาโปรแกรม “Hello World” ที่เล็กที่สุดได้ด้วย
บทความนี้แทบจะข้ามบทบาทของ dynamic linker ซึ่งอาจมองได้ว่าเป็น entry point ตัวจริงของโปรแกรมไป
ถ้าสนใจมุมมองนี้ ดูได้ที่ https://gist.github.com/kenballus/c7eff5db56aa8e4810d39021b2...
ถ้าคุณเป็นคนรัก DOS, “hello, world” ที่เขียนด้วย assembly/machine code บน DOS เคยทำให้เล็กได้ถึง 23 ไบต์: https://github.com/susam/hello
ใน 23 ไบต์นี้ 15 ไบต์เป็นตัวสตริงที่ลงท้ายด้วยเครื่องหมายดอลลาร์เอง ดังนั้นโค้ด machine code จริง ๆ มีเพียง 8 ไบต์จากคำสั่ง x86 สี่คำสั่งเท่านั้น
บทความดี แต่ผมอยากให้ทำเพิ่มอีกสองอย่าง ควรปิด optimization และ inlining ที่ทำให้
printfถูกเปลี่ยนเป็นputsหรือไม่ก็เขียน hello world ที่ใช้putsโดยตรงตั้งแต่แรกจะดีกว่าอีกอย่างคือ ถ้าแบ่งขั้นตอนคอมไพล์เป็นสี่ขั้นคือ preprocessing, compiling, assembling, linking หรือใส่
--save-tempsให้ccแล้วอธิบายไฟล์ที่ถูกสร้างขึ้น ก็น่าจะดี พอเห็น pipeline ด้วยตัวเอง ส่วนที่ดูเหมือนเวทมนตร์จะลดลงไปมากทำให้นึกถึงการบ้านที่เคยชอบในวิชาการเขียนโปรแกรมระบบที่มหาวิทยาลัย: เป็นโจทย์ประมาณว่า “ให้โค้ด C++ hello world มาหนึ่งชิ้น แล้วส่งไบนารีที่คอมไพล์แล้วให้มีขนาดเล็กที่สุดเท่าที่เป็นไปได้”
ยังจำได้ว่าใช้เครื่องมืออย่าง
readelfกับobjdumpส่องดูโปรแกรม แล้วค่อย ๆ ลอกเลเยอร์กับการปรับแต่งของคอมไพเลอร์ออก จนลดเหลือไบนารีที่เล็กที่สุดซึ่งยังพิมพ์ “hello world” ได้แน่นอนว่าพอลองค้นดูก็พบว่ามีคนทำได้ดีกว่านักศึกษาเยอะมาก: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....
แค่สร้างไบนารีที่เล็กที่สุดซึ่งพิมพ์ hello world แล้วอ้างได้ว่าเทียบเท่ากันในเชิงความหมายก็น่าจะพอไม่ใช่หรือ แม้รวมข้อมูลสตริงด้วย ก็น่าจะใช้คำสั่ง x86 แค่ราว ๆ สิบกว่าคำสั่ง
ส่วนตัวแล้วผมชอบประหยัดพื้นที่ในคอมพิวเตอร์ของตัวเอง เลยรู้สึกว่าน่าสนุก แต่ทุกวันนี้มีโปรแกรมจำนวนมากที่ถูกเขียนออกมาขนาดเกิน 10MiB, 20MiB, 50MiB, 100MiB บางตัวทำขึ้นในสภาพแวดล้อมเชิงพาณิชย์เพื่อวัตถุประสงค์เชิงพาณิชย์ก็จริง แต่ก็มีหลายโปรแกรมที่บอกว่าเขียนขึ้นเพื่อความสนุกล้วน ๆ ไม่มีความสุขจากการใช้โปรแกรมขนาดเล็กบ้างหรือ
การจบแบบ “เลยเที่ยงคืนแล้ว คงต้องไปนอน” กลับเป็น บทสรุปที่สมบูรณ์แบบ สำหรับบทความนี้
น่าเสียดายที่เหมือนกับการเจาะลึก “hello world” หลาย ๆ ครั้ง บทความนี้ก็หยุดอยู่ที่ system call
writeแล้วข้ามส่วนที่เหลือไปแบบคร่าว ๆก่อนถึง system call โดยแก่นแล้วก็เป็นแค่ลูกโซ่ของการเรียกฟังก์ชันที่
printfเรียกputs,putsเรียกwrite, ส่งchar const*ต่อกันไปพร้อมทำบัญชีเล็กน้อย ซึ่งโดยส่วนตัวไม่ใช่ส่วนที่น่าสนใจที่สุดส่วนที่น่าสนใจและซับซ้อนจริง ๆ เริ่มหลัง system call ต่างหาก เคอร์เนลเชื่อม
stdoutของโปรเซสเข้ากับอินพุตของ terminal emulator และเทอร์มินัลก็เตรียม framebuffer ผ่านไลบรารีเรนเดอร์ฟอนต์กับไดรเวอร์ GPU อ่านเส้นขอบตัวอักษรของฟอนต์ที่ตรงกับไบต์อักขระจากดิสก์ ปรับให้เข้ากับ viewport แล้วใช้การปรับขนาด·kerning·font metrics จากนั้น GPU จึงทำ rasterization และ anti-aliasingต่อจากนั้น window manager จะ composite กรอบหน้าต่างเทอร์มินัลกับเดสก์ท็อป และถ้ามีความโปร่งใสหรือเอฟเฟกต์กระจกฝ้า ก็ประมวลผลด้วย shader framebuffer ผลลัพธ์จะถูกทำเป็นแพ็กเก็ตเป็นสัญญาณ HDMI หรือ DisplayPort ให้ตรงกับความละเอียดและความลึกสีของมอนิเตอร์ ผ่านสายเคเบิลและวงจรอินพุตของจอ แล้วเปลี่ยนเป็นสัญญาณระบุตำแหน่งพิกเซล วิธีรีเฟรชจะแตกต่างกันไปตาม LCD, OLED, พลาสมา, CRT เช่น WRGB OLED ขนาด 3840×2400 ต้องจัดการซับพิกเซลประมาณ 36.86 ล้านจุด
กระบวนการทั้งหมดนี้เกิดขึ้นภายใน 16.67ms ซึ่งเป็นเวลาหนึ่งเฟรมที่ 60Hz
https://en.wikipedia.org/wiki/Visual_system
ครอบคลุมภายในของ CPython, Windows
conhost, การ rasterize ฟอนต์, การเรนเดอร์ด้วย GPU ฯลฯ: https://gynvael.coldwind.pl/?id=754/dev/nullเรื่องพวกนี้ก็จะไม่เกิดขึ้น_startก็ยังขาดไปด้วย เช่น วิธีที่โปรเซสถือกำเนิดบน Linux โดยเฉพาะexecveที่ค่อนข้างแปลก, กระบวนการโหลดโปรแกรมเข้าสู่หน่วยความจำ,binfmt_*และbinfmt_miscอันทรงพลัง, relocation, exception handling frame, section, ตัวโหลด ELF โดยรวม, รวมถึงการจัดสรรทรัพยากรของระบบปฏิบัติการอย่างmallocที่จำเป็น เป็นต้นประโยคที่ว่า “ต่างจาก Python เราเรียก interpreter เพื่อรันโปรแกรมนี้ไม่ได้” ก็ไม่ได้ถูกต้องเสียทีเดียว
ใช้
tcc -run hello.cก็ทำได้ พูดให้เคร่งครัดแล้วมันใกล้เคียงกับ คอมไพเลอร์ในหน่วยความจำ มากกว่า interpreter ก็เถอะถ้าอยากได้คะแนนความเนิร์ดเพิ่ม ก็ทำให้โปรแกรมพูดว่า “Hellorld” แทน “Hello world” ก็ได้