- 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
- Gọi
execve → kernel nạp tệp ELF
- Diễn giải ELF → ánh xạ các section mã/dữ liệu, chỉ định interpreter
- Dựng stack → lưu tham số, biến môi trường, vector phụ trợ
- Thực thi entrypoint
_start
- 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() là 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
Ý 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
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
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
Đù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ớ
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
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ì
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
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>/mapsthì càng cuộn xuống dưới địa chỉ càng lớnTứ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
Tuy vậy, họ gợi ý trực quan hóa theo chiều ngang sẽ tự nhiên hơn
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
Khuyên rằng chạy bằng strace thì có thể thấy ngay syscall nào gây lỗi
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