- Chỉ một dòng Hello World viết bằng C cũng phải lần lượt đi qua tệp thực thi đã biên dịch, thư viện chuẩn C, lời gọi hệ thống, kernel và terminal thì mới xuất hiện trên màn hình
- Kết quả tạo bằng
gcc hello.c -o hello là một tệp thực thi ELF 64-bit x86-64, và mã _start tại điểm vào 0x1060 trong ELF header sẽ chạy trước
main() do người dùng viết không được bắt đầu trực tiếp mà đi qua _start và __libc_start_main, còn printf("Hello World!\n") được tối ưu thành lời gọi puts() đơn giản hơn
- Chuỗi được lưu dưới dạng dãy byte tại
0x2004 của .rodata, và chuỗi C xác định điểm kết thúc bằng ký tự kết thúc NULL thay vì thông tin độ dài
- Đường đi xuất thực tế tiếp tục qua bộ đệm và khóa của libc, lời gọi hệ thống
write hoặc writev, Linux kernel, pseudo-terminal và quá trình render của terminal emulator, nên có thể khác nhau tùy môi trường chạy
Bắt đầu từ C Hello World
- Chương trình ví dụ là đoạn mã sau được viết bằng C
#include <stdio.h>
int main() {
printf("Hello World!\n");
return 0;
}
- Nó cho ra kết quả giống
print('Hello World!') của Python, nhưng chương trình C không chạy ngay bằng interpreter mà trước hết phải biên dịch
gcc hello.c -o hello
./hello
Hello World!
- Nếu có kiến thức cơ bản về C hoặc assembly thì sẽ khá dễ theo dõi luồng này
Bản chất của tệp thực thi
- Điểm cốt lõi trong kết quả
file hello là ELF executable, x86-64
- Tệp thực thi ELF là định dạng chương trình có thể chạy trên Linux
x86-64 nghĩa là chương trình mã máy dành cho bộ xử lý x86 64-bit
- Nếu xem ELF header bằng
readelf -h hello sẽ thấy Entry point address: 0x1060
- Địa chỉ này là vị trí CPU bắt đầu thực thi sau khi chương trình được nạp
_start và điểm vào của thư viện C
- Khi disassemble bằng
objdump -D hello, sẽ thấy _start ở vị trí 0x1060
_start không phải mã do người dùng tự viết mà là mã được compiler, chính xác hơn là linker, tự động thêm vào
- Mã này thực hiện khởi tạo rồi chạy lời gọi sau
call *0x2f53(%rip) # 3fd8 <__libc_start_main@GLIBC_2.34>
- Hàm đó không được định nghĩa trực tiếp trong chương trình mà nằm ở phía thư viện chuẩn C
- Trong dynamic section của
readelf -d hello sẽ thấy phụ thuộc vào libc.so.6
Shared library: [libc.so.6]
libc.so.6 là thư viện chuẩn C của hệ thống, và tệp .so trên Linux chứa mã có thể được nhiều chương trình dùng chung, tương tự .dll trên Windows
- Thư viện C phụ trách khởi tạo như xử lý đối số dòng lệnh và biến môi trường, sau đó gọi
main() rồi kết thúc chương trình bằng giá trị trả về
Điều thực sự diễn ra trong main()
- Trong kết quả disassemble,
main() nằm tại 0x1149
- Luồng của
main() như sau
- Thiết lập stack frame
- Chuẩn bị đối số cho lời gọi hàm
- Gọi hàm in Hello World
- Dọn dẹp stack frame
- Trả về với mã thoát
0
- Điểm chính là phần chuẩn bị địa chỉ chuỗi làm đối số và gọi
puts@plt
lea 0xeac(%rip),%rax
call 1050 <puts@plt>
- Trong mã nguồn có
printf(), nhưng compiler đã tối ưu thành puts()
printf() là hàm phức tạp hỗ trợ xuất định dạng
- Ví dụ này không dùng tính năng định dạng như chèn biến nên được thay bằng `puts()`` đơn giản hơn
puts() tự thêm ký tự xuống dòng vào cuối chuỗi, vì vậy \n trong chuỗi gốc cũng bị loại bỏ
Cách chuỗi được lưu trữ
- Chuỗi nằm ở địa chỉ
0x2004 trong section .rodata
- Các byte tại vị trí đó như sau
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
- Dãy byte này được diễn giải thành
"Hello World!" và 0x00 ở cuối
0x00 là ký tự kết thúc NULL, dùng để đánh dấu hết chuỗi C
- Chuỗi C không đi kèm thông tin độ dài, nên các hàm nhận chuỗi sẽ xử lý từng byte cho đến khi gặp ký tự kết thúc NULL
- Nếu giữa các chuỗi không có ký tự kết thúc NULL, hàm C có thể nối liền nhiều chuỗi để xử lý hoặc đọc vào vùng nhớ không được phép và kết thúc bằng Segmentation Fault
Đường đi của puts() trong Glibc
puts@plt cuối cùng sẽ dẫn sang phía thư viện chuẩn
- Trong Glibc,
puts() được nối tới _IO_puts
_IO_puts thực hiện các việc sau
- Tính độ dài chuỗi
- Lấy khóa cho luồng xuất
stdout
- Kiểm tra điều kiện rồi gọi
_IO_sputn
- Xuất ký tự xuống dòng
- Mở khóa và trả về số ký tự đã xuất
- Cài đặt nội bộ của Glibc lớn và phức tạp, nên phần tiếp theo chuyển sang luồng của musl libc, một thư viện C nhỏ hơn
Quá trình đi xuống của đầu ra trong musl libc
puts() của musl lấy khóa stdout, gọi fputs() và putc_unlocked('\n', stdout), rồi mở khóa
fputs() tính độ dài chuỗi và gọi fwrite()
fwrite() lại lấy khóa và gọi __fwritex()
__fwritex() kiểm tra trạng thái bộ đệm và nếu cần sẽ gọi con trỏ hàm write của luồng xuất
stdout được định nghĩa với fd = 1, và hàm write ban đầu được gán là __stdout_write
__stdout_write() thực hiện ioctl TIOCGWINSZ rồi gọi __stdio_write()
__stdio_write() thực hiện lời gọi hệ thống bằng SYS_writev
Lời gọi hệ thống và kernel
- Chỉ với thư viện C thì không thể giao tiếp trực tiếp với phần cứng; việc truy cập phần cứng do kernel của hệ điều hành đảm nhiệm
- Yêu cầu xuất cuối cùng sẽ kết thúc ở một lời gọi hệ thống yêu cầu hệ điều hành ghi văn bản vào luồng xuất
- Việc xuất thông thường được thực hiện bằng lời gọi hệ thống
write, còn musl dùng writev để có thể ghi nhiều bộ đệm dưới dạng mảng
- Cài đặt lời gọi hệ thống của musl trên x86-64 được chia từ
__syscall0 đến __syscall6 theo số lượng đối số
- Mỗi hàm sẽ đặt đối số vào các thanh ghi CPU và thực thi lệnh
syscall
- Quyền điều khiển được chuyển sang kernel
- Kernel đọc các tham số trong thanh ghi và thực hiện lời gọi hệ thống được yêu cầu
Sau kernel, cho đến khi hiện lên màn hình
- Linux kernel nhận lời gọi hệ thống
write và ghi dữ liệu vào tệp hoặc luồng đã mở
- Lời gọi hệ thống
write nhận các đối số là file descriptor, bộ đệm cần ghi và số byte cần ghi
- Trong môi trường ví dụ, chương trình
hello chạy trong GNOME terminal emulator và stdout được nối với pseudo-terminal /dev/pts/0
- Kernel lưu thông điệp Hello World vào bộ đệm, rồi terminal emulator đọc nó và hiển thị lên màn hình
- Terminal emulator render văn bản thành frame, sau đó X server hoặc compositor trộn với màn hình của các ứng dụng khác rồi hiển thị lên màn hình thông qua kernel
- Tùy môi trường thực thi, đường đi sau đó có thể khác
- Với đăng nhập từ xa, kernel gửi văn bản tới
sshd, rồi sshd lại chuyển nó về kernel dưới dạng gói mã hóa để gửi qua Internet
- Nếu dùng terminal vật lý và bộ chuyển serial-to-USB, kernel sẽ gửi văn bản thành các gói USB
- Với framebuffer console, kernel sẽ render văn bản thành frame rồi xuất ra màn hình
Độ phức tạp đằng sau một đầu ra nhỏ
- Việc gửi thông điệp Hello World chỉ là một lời gọi hệ thống phát sinh từ một chương trình duy nhất
- Phần mềm và phần cứng hiện đại được cấu thành từ nhiều lớp phức tạp và tinh vi đến mức rất khó lần vết đầy đủ ngay cả với một hành động nhỏ
- Phần giải thích này lược bỏ nhiều chi tiết, ngoại lệ và hoạt động nội bộ của kernel, và chỉ đi theo luồng chính
1 bình luận
Ý kiến trên Hacker News
Vì buồn chán nên tôi đã thử làm một thứ tương tự bằng Rust trên macOS; ngay cả “Hello, world!” dùng
#![no_std],#![no_main]và trực tiếp gọi system callWRITE/EXITthì khi xem bằng Ghidra, dù làm thế nào phần xuất ra cũng vào khoảng 16KBChắc vẫn có thể code golf để giảm thêm, nhưng có vẻ rất có khả năng ai đó đã làm và ghi chép lại rồi
rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:mainNó gọi trực tiếp
ExitProcess,GetStdHandle,WriteFilecủakernel32; vì chỉ là hello world nên trình xử lý panic được để khá sơ sài. Trong file thực thi vẫn còn khá nhiều padding nên có thể nhét thêm mà không tăng kích thước, và cũng có thể giảm bằng những cách “phạm pháp” hơn, nhưng có vẻ không có nhiều ý nghĩaNhân tiện, cơ sở dữ liệu debug PDB liên quan có kích thước 208.896 byte
mainvà dùng_start, đồng thời truyền cả cờ linker để không căn chỉnh sectionXem https://darkcoding.net/software/a-very-small-rust-binary-ind... thì có thể dễ dàng xuống gần 500 byte bằng cách này
Nếu là ngôn ngữ có stack, file thực thi cuối cùng nhiều khả năng sẽ được nạp tối thiểu khoảng hai page: một page chỉ đọc và một page đọc-ghi
Tôi nhớ là nếu áp dụng mọi tối ưu hóa thì hello world cuối cùng vào khoảng 8KB: https://github.com/johnthagen/min-sized-rust
Còn một hang thỏ khác mà Musl đã bỏ qua. Trên Linux, gọi hàm hệ thống không chỉ là trực tiếp dùng
syscallCách “lịch sự” hơn là gọi vDSO. Đây là một thư viện nhỏ kỳ diệu được kernel tự động ánh xạ vào không gian địa chỉ, nhờ đó kernel có thể cung cấp đoạn mã tối ưu để thực hiện system call
Một số system call có thể chạy trong user space khiến bản thân
syscallkhông còn cần thiết, và trước đây vDSO cũng từng chọn một trong các cơ chế gọi kernel nhưint 0x80haysysenterhttps://man7.org/linux/man-pages/man7/vdso.7.html
Trên x86-64, cách gọi system call tiêu chuẩn là lệnh
SYSCALL, còn vDSO chỉ chứa các hàm liên quan đến thời gian và một vài hàm liên quan đến SGXCũng đáng xem bài viết so sánh overhead của các chương trình “Hello World” theo từng ngôn ngữ: https://drewdevault.com/2020/01/04/Slow.html
Bài tiếp theo: https://drewdevault.com/2020/01/08/Re-Slow.html
Cũng có một bài huyền thoại về chương trình nhỏ nhất trên Linux. Chương trình chỉ đơn giản thoát với mã trạng thái 42: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
Trên cùng trang đó cũng có thể tìm được chương trình “Hello World” nhỏ nhất
Bài viết gần như bỏ qua vai trò của dynamic linker, thứ trên thực tế cũng có thể xem là điểm vào thật sự của chương trình
Nếu tò mò về góc nhìn này, có thể xem https://gist.github.com/kenballus/c7eff5db56aa8e4810d39021b2...
Với người yêu thích DOS, “hello, world” viết bằng assembly/mã máy trên DOS từng có thể nhỏ tới 23 byte: https://github.com/susam/hello
Trong 23 byte này, 15 byte là chính chuỗi kết thúc bằng ký hiệu đô la, nên mã máy thực sự chỉ có 8 byte gồm bốn lệnh x86
Bài viết hay, nhưng tôi mong tác giả làm thêm hai điều. Tắt tối ưu hóa và inline đã khiến
printfbị đổi thànhputs, hoặc ngay từ đầu viết hello world dùng trực tiếpputsthì tốt hơnNgoài ra, sẽ tốt nếu chia bước biên dịch thành bốn giai đoạn: tiền xử lý, biên dịch, assembly và link, hoặc thêm
--save-tempsvàoccrồi giải thích các file được tạo ra. Khi tự nhìn pipeline, những phần trông như phép màu sẽ giảm đi rất nhiềuNhớ lại một bài tập mình từng thích trong môn lập trình hệ thống ở đại học: bài tập yêu cầu “cho một mẩu C++ hello world, hãy nộp binary đã biên dịch nhỏ nhất có thể”
Mình còn nhớ đã soi chương trình bằng các công cụ như
readelfvàobjdump, rồi từng chút một bóc tách các lớp và tối ưu hóa của compiler, thu nhỏ nó đến binary nhỏ nhất mà vẫn in ra được “hello world”Tất nhiên, tìm thử thì thấy đã có người làm tốt hơn sinh viên rất nhiều: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....
Chẳng phải chỉ cần tạo binary nhỏ nhất in ra hello world rồi tuyên bố là tương đương về mặt ngữ nghĩa là được sao. Kể cả tính cả dữ liệu chuỗi thì có lẽ chỉ cần cỡ hơn chục lệnh x86 là đủ
Cá nhân mình thích tiết kiệm dung lượng trên máy tính của mình nên thấy việc đó thú vị, nhưng ngày nay có rất nhiều chương trình dung lượng 10MiB, 20MiB, 50MiB, hơn 100MiB được viết ra. Có những thứ được làm trong môi trường thương mại vì mục đích thương mại, nhưng cũng có nhiều chương trình nói là viết chỉ vì niềm vui thuần túy. Chẳng lẽ không có niềm vui nào trong việc dùng chương trình nhỏ gọn sao
Kiểu kết bài “quá nửa đêm rồi nên phải đi ngủ thôi” thật ra lại là cái kết hoàn hảo cho bài viết này
Đáng tiếc là cũng như nhiều bài đào sâu “hello world”, bài này cũng dừng ở system call
writerồi lướt qua phần còn lạiTrước system call thì về bản chất chỉ là chuỗi lời gọi hàm trong đó
printfgọiputs,putsgọiwrite, truyềnchar const*và làm thêm chút sổ sách, nên với cá nhân mình đó không phải phần thú vị nhấtPhần thật sự thú vị và phức tạp bắt đầu sau system call. Kernel nối
stdoutcủa process vào đầu vào phía terminal emulator, còn terminal chuẩn bị framebuffer thông qua thư viện render font và driver GPU. Nó đọc outline font tương ứng với các byte ký tự từ đĩa, căn theo viewport, áp dụng đổi kích thước, kerning và font metrics, rồi GPU thực hiện rasterization và anti-aliasingSau đó window manager compositing khung cửa sổ terminal với desktop; nếu có hiệu ứng trong suốt hay kính mờ thì xử lý bằng shader. Framebuffer kết quả được packetize thành tín hiệu HDMI hoặc DisplayPort phù hợp với độ phân giải và độ sâu màu của màn hình, đi qua cáp và mạch đầu vào của display rồi biến thành tín hiệu định địa chỉ pixel. Tùy LCD, OLED, plasma hay CRT mà cách refresh khác nhau; chẳng hạn OLED WRGB 3840×2400 phải xử lý khoảng 36,86 triệu subpixel
Tất cả những việc này diễn ra trong 16.67ms, tức thời gian của một frame ở 60Hz
https://en.wikipedia.org/wiki/Visual_system
Bài này nói về nội bộ CPython, Windows
conhost, rasterization font, GPU rendering, v.v.: https://gynvael.coldwind.pl/?id=754/dev/nullthì những chuyện đó sẽ không xảy ra_startcũng bị bỏ sót. Chẳng hạn cách một process được sinh ra trên Linux, đặc biệt làexecvekhá kỳ lạ, quá trình chương trình được load vào bộ nhớ,binfmt_*vàbinfmt_miscđầy sức mạnh, relocation, exception handling frame, section, toàn bộ ELF loader, phân bổ tài nguyên hệ điều hành bao gồm cảmalloccần thiết, v.v.Câu “khác với Python, ta không thể gọi interpreter để chạy chương trình này” không hẳn đúng
Dùng
tcc -run hello.cthì làm được. Nói nghiêm ngặt thì nó gần với compiler trong bộ nhớ hơn là interpreterNếu muốn thêm điểm lập dị, hãy làm cho chương trình nói “Hellorld” thay vì “Hello world”