3 điểm bởi GN⁺ 2024-04-09 | Chưa có bình luận nào. | Chia sẻ qua WhatsApp
  • Khám phá thế giới trừu tượng ẩn sau chương trình Hello World hiện đại

    • Bài viết này nói về chương trình Hello World được viết bằng C. Trong số các ngôn ngữ cấp cao mà bạn không cần phải bận tâm ngôn ngữ thực sự làm gì trước khi chương trình chạy trên interpreter/compiler/JIT, thì C là ngôn ngữ ở mức thấp nhất nhưng vẫn thuộc nhóm đó.
    • Ban đầu bài viết được định hướng để bất kỳ ai có nền tảng lập trình đều có thể hiểu, nhưng có lẽ sẽ hữu ích hơn nếu ít nhất có kiến thức về C hoặc assembly.
  • Bắt đầu với chương trình Hello World

    • Mọi người hẳn đều quen thuộc với chương trình Hello World. Trong Python, có lẽ chương trình đầu tiên bạn viết là thứ gì đó như print('Hello World!').
    • Trong bài này, chúng ta sẽ xem xét Hello World được viết bằng ngôn ngữ lập trình C. Trong C, bạn không thể gọi interpreter để chạy chương trình. Trước hết phải chạy compiler để chuyển nó thành mã máy mà bộ xử lý máy tính có thể thực thi trực tiếp.
  • Phân tích chương trình của chúng ta

    • Khi phân tích tệp chương trình đã được biên dịch, có thể thấy đó là tệp thực thi ELF và dành cho kiến trúc tập lệnh x86-64.
    • Tệp thực thi ELF tương đương với tệp .exe trên Windows trong Linux.
    • x86-64 là kiến trúc CPU đã được dùng trên PC kể từ khi IBM PC được giới thiệu vào năm 1981.
    • Tệp này chứa mã máy, ngôn ngữ duy nhất mà CPU có thể hiểu.
  • Phân tích mã assembly

    • Hãy tìm entry point, tức địa chỉ bắt đầu của chương trình, và phân tích mã assembly.
    • Assembly là cách biểu diễn mã máy ở dạng con người có thể đọc được.
    • Có thể thấy đoạn mã khởi tạo được compiler (chính xác hơn là linker) tự động thêm vào, và nó gọi hàm __libc_start_main.
    • Nhưng đoạn mã này không được định nghĩa trong chương trình của chúng ta mà nằm ở đâu đó khác.
  • Thư viện chuẩn C

    • Hàm __libc_start_main được định nghĩa trong libc.so.6, thư viện chuẩn C của hệ thống.
    • Thư viện chuẩn C là tập hợp các routine và hàm mà gần như mọi chương trình trên máy tính của chúng ta đều sử dụng.
    • Thư viện C thực hiện công việc khởi tạo rồi gọi hàm main() mà chúng ta viết. Khi main() trả về, nó kết thúc chương trình với mã thoát mà chúng ta cung cấp.
  • Phân tích hàm main()

    • Trong hàm main(), stack frame được thiết lập, địa chỉ của chuỗi Hello World được đặt làm đối số cho lời gọi hàm, sau đó hàm puts() được gọi.
    • puts() xuất hiện thay cho printf() do compiler đã tối ưu hóa. printf() phức tạp hơn, trong khi puts() chỉ đơn giản in ra một chuỗi không có định dạng.
  • Chuỗi Hello World

    • Chuỗi có dạng "Hello World!" theo sau bởi ký tự kết thúc NULL.
    • Trong C, chuỗi không đi kèm thông tin độ dài, nên dùng ký tự kết thúc NULL để đánh dấu điểm kết thúc. Nếu không có ký tự NULL kết thúc, chương trình có thể đọc vào vùng nhớ không được phép và chết vì Segmentation Fault.
    • Do tối ưu hóa của compiler, ký tự xuống dòng (\n) dùng trong printf() đã bị loại bỏ. puts() vốn tự thêm xuống dòng sau khi in chuỗi.
  • Hàm puts()

    • Hàm puts() lại tiếp tục gọi mã bên trong thư viện chuẩn.
    • Nếu xem mã của glibc, có thể thấy chuỗi gọi theo thứ tự _IO_puts -> _IO_new_file_xsputn, nhưng mã khá phức tạp nên khó giải thích.
    • Với musl libc thì đơn giản hơn. Nó gọi theo chuỗi puts -> fputs -> fwrite -> __fwritex -> __stdio_write -> syscall.
  • System call

    • Dù thư viện C có lớn đến đâu thì nó cũng không thể giao tiếp trực tiếp với phần cứng. Chỉ kernel mới làm được điều đó.
    • Vì vậy, lời gọi puts() rốt cuộc kết thúc ở việc yêu cầu OS làm gì đó. Trong trường hợp này là ghi chuỗi vào luồng đầu ra.
    • musl libc sử dụng system call writev, cho phép ghi nhiều buffer cùng lúc.
    • System call được thực hiện bằng cách thiết lập tham số trong các thanh ghi rồi chạy lệnh syscall. Khi đó quyền điều khiển được chuyển sang kernel, và kernel sẽ đọc tham số để thực hiện system call.
  • Kernel

    • Kernel Linux phải thực hiện hành động được yêu cầu bởi system call. System call write chỉ thị cho kernel ghi vào một tệp hoặc luồng đang mở trong hệ thống tệp.
    • write nhận 3 tham số gồm file descriptor cần ghi, buffer cần ghi và số byte cần ghi.
    • Thực sự ghi vào đâu còn tùy tình huống. Nếu là terminal emulator thì nó xuất hiện như virtual terminal (pty), nếu là đăng nhập từ xa thì được chuyển tới sshd, nếu là terminal vật lý thì đi qua bộ chuyển đổi serial-USB. Nếu là framebuffer console thì kernel sẽ render văn bản và hiển thị lên màn hình.
  • Kết luận

    • Các hệ thống phần mềm hiện đại vận hành cực kỳ phức tạp và tinh vi trên phần cứng, nên việc cố gắng hiểu hoàn toàn cách máy tính thực hiện một việc nhỏ là điều gần như vô nghĩa.
    • Để giải thích mọi thứ, bài viết buộc phải lược bỏ rất nhiều phần.
    • Việc gửi thông điệp Hello World chỉ là một trong vô số system call và chương trình đang chạy trên máy tính lúc này.

Ý kiến của GN⁺

  • Đây là bài viết cho thấy mỗi tầng của hệ thống máy tính che giấu sự phức tạp của tầng bên dưới thông qua trừu tượng hóa, nhờ đó lập trình viên có thể phát triển ứng dụng thuận tiện hơn.
  • Mặt khác, nó cũng giúp ta nhận ra có bao nhiêu thứ diễn ra bên dưới để thực thi chỉ một dòng ứng dụng, và vì sao việc debug lại khó đến vậy.
  • Tôi cho rằng mọi lập trình viên nên hiểu rõ ít nhất đến tầng hệ thống nằm bên dưới ngôn ngữ mà mình chủ yếu sử dụng. Không cần biết toàn bộ, nhưng việc hiểu phần bị trừu tượng hóa thực sự hoạt động như thế nào là rất quan trọng.
  • Ngay cả khi dùng ngôn ngữ cấp cao, nếu học trước các khái niệm lập trình hệ thống như cấu trúc bộ nhớ, stack và heap, system call... thì sẽ rất hữu ích cho việc debug và tối ưu hiệu năng.
  • Lập trình viên ứng dụng có lẽ hiếm khi trực tiếp đụng đến compiler hay thư viện C, nhưng hiểu chương trình mình viết rốt cuộc sử dụng hệ thống theo cách nào là điều thiết yếu để trở thành một lập trình viên giỏi.

Chưa có bình luận nào.

Chưa có bình luận nào.