1 điểm bởi GN⁺ 2025-10-26 | 1 bình luận | Chia sẻ qua WhatsApp
  • Phân tích kỹ thuật khám phá quá trình kernel tạo và khởi tạo tiến trình thông qua lời gọi hệ thống execve trước khi chương trình được chạy
  • Lời gọi này truyền vào đường dẫn tệp thực thi, tham số và biến môi trường, và dựa trên đó kernel tải tệp thực thi theo định dạng ELF
  • Tệp ELF chứa mã lệnh, dữ liệu, symbol, thông tin liên kết động, v.v., và kernel diễn giải chúng để thực hiện ánh xạ bộ nhớ và khởi tạo stack
  • Sau đó kernel chuyển quyền điều khiển tới entrypoint _start, và chỉ sau khi runtime của ngôn ngữ được khởi tạo thì hàm main do người dùng định nghĩa mới được gọi
  • Quá trình này cho thấy cấu trúc cộng tác giữa hệ điều hành, compiler và runtime, đồng thời rất quan trọng để hiểu chương trình được thực thi như thế nào ở cấp độ hệ thống

Điểm khởi đầu của việc thực thi chương trình: lời gọi execve

  • Trên Linux, việc thực thi chương trình bắt đầu thông qua lời gọi hệ thống execve
    • Có dạng execve(const char *filename, char *const argv[], char *const envp[]), truyền vào tên tệp thực thi, danh sách tham số và danh sách biến môi trường
    • Kernel dựa vào đó để quyết định sẽ chạy chương trình nào trong môi trường nào
  • Trong các ngôn ngữ cấp cao, lời gọi này được bọc bởi API thực thi tiến trình của thư viện chuẩn
    • Ví dụ: std::process::Command của Rust nội bộ sẽ gọi execve
    • Nó thực hiện quá trình chuyển tên lệnh thành đường dẫn đầy đủ, tương tự như cơ chế tìm kiếm PATH của shell
  • Với script có shebang(#!), kernel sẽ dùng interpreter được chỉ định để chạy chương trình
    • Ví dụ: #!/usr/bin/python3 → chạy bằng interpreter Python

ELF: cấu trúc của tệp thực thi

  • Tệp thực thi trên Linux tuân theo định dạng ELF(Executable and Linkable Format)
    • ELF là định dạng tệp thực thi tiêu chuẩn chứa mã lệnh, dữ liệu, symbol, thông tin tái định vị, v.v.
    • Các OS khác dùng định dạng riêng như Mach-O(macOS), PE(Windows))
  • ELF header chứa thông tin về cấu trúc tệp và cách bố trí trong bộ nhớ
    • Ví dụ các mục: ELF Magic, Class, Entry point address, Program headers, Section headers
    • Entry point address là địa chỉ của lệnh đầu tiên mà chương trình sẽ thực thi
  • Trong ví dụ ELF header, đây là tệp thực thi ELF32 cho kiến trúc RISC-V, với địa chỉ 0x10358 được chỉ định làm entrypoint

Các thành phần bên trong ELF

  • Tệp ELF được cấu thành từ nhiều section
    • .text: mã thực thi
    • .data: biến toàn cục đã được khởi tạo
    • .bss: biến toàn cục chưa được khởi tạo
    • .plt: bảng dùng để gọi thư viện dùng chung
    • .symtab, .strtab: bảng symbol và bảng chuỗi
  • PLT(Procedure Linkage Table) hỗ trợ việc gọi các hàm trong thư viện dùng chung
    • Ví dụ: printf, malloc của libc
    • Section PT_INTERP của ELF chỉ định dynamic linker(interpreter)
  • Kernel đọc ELF để đặt các section có thể nạp vào bộ nhớ, và nếu cần sẽ áp dụng các tính năng bảo mật như ASLR, NX bit

Bảng symbol và liên kết lúc runtime

  • Bảng symbol(symtab) của ELF chứa thông tin địa chỉ của hàm và biến
    • Ví dụ: có các entry như _start, main, __libc_start_main
    • Ngay cả một chương trình “Hello, World!” đơn giản cũng có thể chứa hơn 2300 symbol
  • Phần lớn trong số đó đến từ thư viện chuẩn và mã khởi tạo runtime
    • Vì có liên kết với các implementation libc như musl hoặc glibc
  • Sau khi nạp từng section của ELF, kernel sẽ chuyển quyền điều khiển cho interpreter(dynamic linker)
    • Interpreter xử lý tái định vị(relocation), ngẫu nhiên hóa địa chỉ(ASLR), thiết lập quyền thực thi(NX bit), v.v.

Quá trình khởi tạo stack

  • Trước khi chương trình chạy, kernel phải trực tiếp dựng stack
    • Stack được dùng cho biến cục bộ, frame gọi hàm, truyền tham số, v.v.
  • argv, envp được truyền vào khi gọi execve sẽ được lưu trên stack
    • Chương trình dùng chúng để truy cập tham số dòng lệnh và biến môi trường
  • Kernel cũng đưa vector phụ trợ ELF(auxv) vào stack
    • Gồm khoảng hơn 30 mục như kích thước trang, metadata ELF, thông tin hệ thống, v.v.
    • Ví dụ: AT_PAGESZ chỉ định kích thước trang bộ nhớ (ví dụ: 4KiB)
  • Trong ví dụ trình giả lập RISC-V, con trỏ stack(sp) bắt đầu từ địa chỉ cao, rồi xếp tham số, biến môi trường và vector phụ trợ theo thứ tự ngược lại

Entrypoint và hàm _start

  • Entrypoint của ELF được chỉ định là địa chỉ của hàm _start
    • _start là đoạn mã không gian người dùng đầu tiên mà kernel chuyển quyền điều khiển tới
  • Phần lớn các ngôn ngữ sẽ thực hiện khởi tạo runtime trong _start, rồi mới gọi main
    • Ví dụ: std::rt::lang_start của Rust, __libc_start_main của C
  • Trong ví dụ Rust, có thể dùng thuộc tính #![no_std], #![no_main] để tự định nghĩa trực tiếp _start mà không cần runtime
    • Trong _start, có thể đọc argc, argv, envp từ stack rồi gọi con trỏ hàm main
  • Runtime theo từng ngôn ngữ sẽ thực hiện các tác vụ khởi tạo đặc thù của ngôn ngữ như global constructor, thread-local storage, xử lý ngoại lệ, v.v.

Toàn bộ luồng trước khi gọi main()

  • Toàn bộ quá trình có thể được tóm tắt như sau
    1. Gọi execve → kernel nạp tệp ELF
    2. Diễn giải ELF → ánh xạ các section mã/dữ liệu, chỉ định interpreter
    3. Dựng stack → lưu tham số, biến môi trường, vector phụ trợ
    4. Thực thi entrypoint _start
    5. Khởi tạo runtime xong thì gọi main()
  • Chuỗi quá trình này cho thấy cấu trúc phối hợp giữa kernel của hệ điều hành, định dạng ELF và runtime ngôn ngữ
  • Linux kernel thực tế còn bao gồm thêm logic nội bộ như không gian địa chỉ, bảng tiến trình, quản lý nhóm, v.v., nhưng bài viết này giải thích luồng cốt lõi ở giai đoạn trước đó

Kết luận và hiệu đính

  • Quá trình thực thi trước main()sự kết hợp giữa khởi tạo ở cấp kernel và thiết lập runtime
  • Ngay cả một chương trình “Hello, World!” đơn giản cũng phải đi qua cấu trúc ELF phức tạp và quá trình khởi tạo runtime mới có thể chạy được
  • Trong phiên bản đầu của bài viết, một phần logic nạp section được quy cho kernel, nhưng sau đó đã được hiệu đính rằng thực tế đó là vai trò của ELF interpreter
  • Phân tích này là tài liệu nền tảng hữu ích để hiểu lập trình hệ thống, compiler và kiến trúc OS

1 bình luận

 
GN⁺ 2025-10-26
Ý kiến Hacker News
  • Giải thích về quá trình liên kết động của tệp ELF
    Kernel ánh xạ các segment PT_LOAD của ELF, tải trình liên kết động (ld.so) được chỉ định bởi PT_INTERP rồi chuyển quyền điều khiển cho nó
    Sau đó trình liên kết động tự tái định vị (relocation) và tải các đối tượng dùng chung cần thiết bằng mmap/mprotect
    Cấu trúc này được ví là tương tự với cơ chế shebang(#!) của script

    • Kernel hoàn toàn không quan tâm đến thông tin section, mà chỉ xử lý các segment PT_LOAD
      Chia sẻ trải nghiệm trước đây từng cố chèn một tệp tùy ý vào ELF bằng objcopy nhưng kernel không tải nên đã rất bối rối
      Cuối cùng đã tự tạo công cụ vá bảng program header và nói rằng tính năng này cũng đã được thêm vào linker mold
      Bài liên quan: Self-contained Lone Lisp Applications
    • Thừa nhận tác giả trước đó đã chỉnh sửa nội dung sai và nói sẽ sửa lại
    • Trên Linux, loader hoạt động trong user space, nên họ luôn thắc mắc vì sao không có nhiều loader đa dạng hơn
  • Nói rằng đã thử nghiệm đóng gói toàn bộ mã trước main() hoặc không cần main()
    Bài liên quan: Packing a codebase into a single function

    • Đọc xong thấy thú vị vì hóa ra khá đơn giản và không hề mong manh như tưởng tượng
      Đùa rằng chỉ cần đổi mọi hàm thành dạng main(100+n, ...) là được
  • Nếu hứng thú với chủ đề này thì hãy xem cpu.land do họ tạo ra
    Nội dung tập trung vào đa nhiệm và quá trình nạp mã hơn là bố cục bộ nhớ

    • Gửi lời cảm ơn vì thực sự rất thích cpu.land
  • Thắc mắc có bao nhiêu dự án C tránh thư viện chuẩn và chỉ gọi trực tiếp Linux syscall
    Cảm thấy viết mã theo cách này thú vị hơn nhiều

    • Lập luận rằng dùng syscall trực tiếp thực ra lại kém hiệu quả hơn
      Những chức năng như ALSA, DRM có nhiều lợi ích hơn nếu truy cập qua thư viện hệ thống thay vì syscall của kernel
      Giải thích rằng cách này tốt hơn cách tiếp cận kiểu Windows về mặt tính di động và khả năng bảo trì
    • Bổ sung rằng trên Windows, nếu chỉ dùng Win32 API thì không cần liên kết với C runtime
    • Nói rằng bản thân trước đây cũng từng làm dự án liblinux để viết chương trình chỉ bằng syscall
      Giờ thì đã dừng vì các header nolibc của Linux đã khá hoàn chỉnh,
      nhưng hiện đang phát triển một ngôn ngữ thông dịch Lisp dựa trên syscall
      Họ nói đây là một hành trình rất thú vị vì là thử nghiệm tự xây dựng Linux user space trực tiếp bằng system call
    • Cố gắng giữ tính di động, nhưng file descriptor quá tiện nên khó mà từ bỏ
    • Bổ sung rằng nhiều mã driver thực tế đúng là chỉ dùng syscall
  • Giải thích rằng trình thông dịch ELF (ld.so) sẽ đảm nhiệm toàn bộ quá trình nạp sau khi các segment ELF ban đầu đã được ánh xạ
    execve ánh xạ các segment PT_LOAD, điền aux vector lên stack rồi
    nhảy tới entry point của trình thông dịch ELF
    Kernel không biết gì về PLT/GOT

  • Là người giảng dạy chủ đề này ở đại học, họ nói sinh viên thường bị rối vì sơ đồ bộ nhớ
    Giáo trình thường vẽ địa chỉ càng cao thì càng ở phía trên, nhưng tiến trình Linux thực tế lại
    hiển thị địa chỉ thấp ở trên, địa chỉ cao ở dưới
    Nếu xem /proc/<pid>/maps thì càng cuộn xuống dưới địa chỉ càng lớn
    Tức là cách nói “heap tăng lên trên (stack tăng xuống dưới)” chỉ là hướng theo giá trị số,
    còn về mặt trực quan thì lại ngược lại
    Họ đề xuất nếu vẽ như IDE, càng xuống dưới địa chỉ càng tăng, thì sẽ trực quan hơn nhiều

    • Dù sao thì stack vẫn tăng khi stack pointer giảm, nên cách nói “tăng xuống dưới” vẫn đúng
      Tuy vậy, họ gợi ý trực quan hóa theo chiều ngang sẽ tự nhiên hơn
    • Bản thân họ cũng từng gặp đúng sự bối rối này, và nhớ lại rằng cách ghi địa chỉ little-endian cũng từng khiến mình rối
    • Có người phản bác rằng nếu nghĩ đến cách đồ vật thật được chồng lên nhau thì cách nói “stack tăng xuống dưới” không hề trực quan
  • Nói rằng họ thích làm các thí nghiệm kiểu này với những vi điều khiển PIC16 đời cũ
    Cảm thấy rất thú vị khi tự tay xử lý stack pointer, timer, thiết lập biến, v.v.

  • Chia sẻ trải nghiệm liên quan đến shebang(#!)
    Một ứng dụng Java báo lỗi không tìm thấy script thực thi,
    nhưng vấn đề thực sự là đường dẫn shebang của script bị sai
    Trên máy cục bộ thì chạy bình thường, nhưng trên máy chủ từ xa thì đường dẫn interpreter khác nên phát sinh lỗi

    • Nói rằng đây không chỉ là vấn đề của Java, mà có thể xảy ra với mọi chương trình gặp lỗi ENOENT
      Khuyên rằng chạy bằng strace thì có thể thấy ngay syscall nào gây lỗi
    • Chia sẻ một bài viết phân tích cấu trúc shebang: What the #! means
    • Bổ sung rằng để kernel hỗ trợ shebang thì cần bật CONFIG_BINFMT_SCRIPT=y
  • Nói rằng khi debug họ luôn bị lẫn không biết thứ tự tái định vị của binary chính được áp dụng vào lúc nào
    Họ mô tả chuyện linker tự giải quyết symbol của chính nó trước hay sau giống như một dạng ma thuật đen

  • Chỉ ra rằng liên kết ở phần “lang_start function (defined here)” trong Markdown đang bị hỏng