3 điểm bởi GN⁺ 2024-04-09 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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__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
  • Kết quả chạy như sau
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 helloELF 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!"0x00 ở cuối
  • 0x00ký 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()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

 
GN⁺ 2024-04-09
Ý 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 call WRITE/EXIT thì khi xem bằng Ghidra, dù làm thế nào phần xuất ra cũng vào khoảng 16KB
    Chắ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

    • Trên Windows, một chương trình tương tự có kích thước 3072 byte, được biên dịch bằng rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:main
      Nó gọi trực tiếp ExitProcess, GetStdHandle, WriteFile của kernel32; 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ĩa
      Nhân tiện, cơ sở dữ liệu debug PDB liên quan có kích thước 208.896 byte
    • Muốn làm nhỏ nhất thì phải bỏ hẳn main và dùng _start, đồng thời truyền cả cờ linker để không căn chỉnh section
      Xem 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
    • Code golf thì vui, nhưng cũng nên xem kích thước page ngày nay là bao nhiêu
      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
    • Dự án min-sized-rust tổng hợp rất nhiều tối ưu hóa để giảm kích thước binary Rust
      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
    • XNU không tải Mach-O nhỏ hơn một page, nên đáng tiếc là trên nền tảng đó không có nhiều đất để chơi trò binary nhỏ
  • 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 syscall
    Cá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 syscall khô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 0x80 hay sysenter
    https://man7.org/linux/man-pages/man7/vdso.7.html

    • Chỉ trên x86 32-bit thì vDSO mới chứa shim system call nhanh dùng chung
      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 SGX
  • Cũ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 printf bị đổi thành puts, hoặc ngay từ đầu viết hello world dùng trực tiếp puts thì tốt hơn
    Ngoà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-temps vào cc rồ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ều

  • Nhớ 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ư readelfobjdump, 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....

    • Không rõ việc mẩu đó là C++ có ý nghĩa gì không
      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à đủ
    • Nếu đây là một bài tập thú vị, mình thắc mắc vì sao không thấy nhiều người hơn tạo binary nhỏ nhất có thể cho các chương trình không phải “hello world”
      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 write rồi lướt qua phần còn lại
    Trước system call thì về bản chất chỉ là chuỗi lời gọi hàm trong đó printf gọi puts, puts gọi write, truyền char 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ất
    Phần thật sự thú vị và phức tạp bắt đầu sau system call. Kernel nối stdout củ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-aliasing
    Sau đó 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

    • Phần giải thích hay đấy, nhưng rốt cuộc vẫn dừng ở hệ thị giác của con người, mà chỗ đó cũng lại là phần thật sự thú vị và phức tạp :)
      https://en.wikipedia.org/wiki/Visual_system
    • Nếu thích kiểu đào sâu như vậy, có lẽ bạn cũng sẽ thích bài của Gynvael Coldwind phân tích cách Python hello world chạy trên Windows
      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
    • Phần lớn những thứ này không liên quan đến bản thân chương trình. Ví dụ nếu pipe output vào /dev/null thì những chuyện đó sẽ không xảy ra
    • Những gì xảy ra trước _start cũng bị bỏ sót. Chẳng hạn cách một process được sinh ra trên Linux, đặc biệt là execve khá kỳ lạ, quá trình chương trình được load vào bộ nhớ, binfmt_*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ả malloc cầ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.c thì làm được. Nói nghiêm ngặt thì nó gần với compiler trong bộ nhớ hơn là interpreter
    Nế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”