Trong Rust vẫn có mã được chạy trước cả main
(grack.com)- Nhị phân Rust đi qua giai đoạn khởi tạo runtime trước
fn main(), và ở giai đoạn này các tác vụ như xử lý panic·unwinding và chuyển đổi tham số chương trình sẽ được thực hiện - Khi bộ nạp của hệ điều hành chuyển quyền điều khiển tới entry point, runtime C và runtime Rust sẽ chạy các hàm khởi tạo, và có thể đặt mã pre-main bằng
#[unsafe(link_section = "...")]và cơ chế constructor - Linker section gom dữ liệu do nhiều crate gửi vào về một chỗ tại thời điểm tạo nhị phân, còn
link-sectioncho phép xử lý chúng như Rust slice - Kết hợp
ctorvàlink-sectioncó thể thiết lập các mẫu như đăng ký CLI subcommand hay sắp xếp string interning pool trướcmain, rồi sau đó đọc mà không cần khóa - Cách này mang lại khả năng tổng hợp không cấp phát và inversion of control, nhưng cần chọn phạm vi áp dụng cẩn thận vì có các giới hạn như khó loại bỏ dead code, ràng buộc của constructor, khác biệt nền tảng và hạn chế tương thích với Miri
Giai đoạn trước main của nhị phân Rust
- Mọi nhị phân Rust đều có
fn main(), nhưng luồng thực thi thực tế chỉ tớimainsau khi đi qua bộ nạp của hệ điều hành và quá trình khởi tạo runtime - C có C runtime thường được nhận diện qua
libc, còn Rust có runtime riêng thông qua standard library và xây dựng các trừu tượng cấp cao hơn trên C runtime - Mục đích của runtime là tích hợp mã của lập trình viên với hệ điều hành của nền tảng
- C runtime thiết lập các dịch vụ runtime như cấp phát, truy cập tệp và thread-local storage ở giai đoạn trước
main - Ở thời điểm này Rust chuẩn bị xử lý panic và unwinding, đồng thời chuyển các tham số chương trình kiểu C sang giao diện
std::env::args - Giai đoạn pre-main chạy trước mã người dùng, là đơn luồng, và có môi trường với thứ tự có thể dự đoán được nên phù hợp cho khởi tạo mang tính xác định
Entry point
- Một nhị phân bắt đầu khi bộ nạp của hệ điều hành đưa nhị phân vào bộ nhớ, thiết lập môi trường và chuyển quyền điều khiển
- Trên Linux, entry point được lưu trong trường
e_entrycủa ELF header, và mặc định linker sẽ đặt địa chỉ của symbol_start - Windows cũng có hook tương tự, và tệp thực thi bắt đầu từ hàm
_WinMainCRTStartup - Giai đoạn bootstrap runtime ban đầu là một cây gọi hàm tĩnh để khởi tạo file I/O, allocator và các thành phần tương tự
- Khi runtime trở nên phức tạp hơn, cây gọi khởi tạo tĩnh cũng phình to, và nhị phân bắt đầu chứa nhiều tính năng C runtime có thể cần hoặc không cần
- Khi linker có thể loại bỏ mã không dùng trước khi tạo nhị phân, cần có một cách khác để thay thế cây gọi khởi tạo tĩnh
- Cách
__attribute__((constructor))của GCC đặt danh sách con trỏ hàm khởi tạo vào một vùng liên tục trong nhị phân, rồi C runtime duyệt qua và gọi chúng khi khởi động - Constructor sau đó có thể được gán độ ưu tiên, ví dụ
malloccó thể cần được khởi tạo trước file I/O có buffer - Runtime
glibchiện đại trên Linux lưu con trỏ hàm trong.init_array, và có thể quyết định thứ tự chạy bằng hậu tố số - Các giá trị ưu tiên từ 100 trở xuống được dành riêng cho chính runtime, nên mã dùng C runtime phải dùng 101 trở lên
- Trong Rust, có thể đặt con trỏ hàm khởi tạo bằng các thuộc tính như
#[used]và#[unsafe(link_section = ".init_array.101")]
linktime: ctor, link-section và các crate khác
- Ví dụ hoạt động trên Linux và nhiều bản BSD, nhưng không được thiết kế như ví dụ đa nền tảng hoàn chỉnh
- macOS hỗ trợ các symbol
startvàstopnhưng dùng tên khác, còn Windows không hỗ trợstartvàstopnhưng có quy tắc sắp xếp section về thực chất là tương đương ctorvàlink-sectionlà các crate thuộc dự ánlinktime, trừu tượng hóa khác biệt giữa các nền tảng và độ phức tạp của công việc với linkerinventoryvàlinkmelà các crate phổ biến được xây dựng trên cùng nguyên lý, nhưng có giới hạn đối với ví dụ này- Crate
ctorxử lý phần boilerplate để đăng ký constructor theo cách đa nền tảng - Hàm được gắn thuộc tính như
#[ctor(unsafe, priority = 101)]sẽ được C runtime gọi sau khi linker sắp xếp, kể cả khi không được gọi trực tiếp trong mã
Section và linker script
- Trình biên dịch có thể đặt dữ liệu hoặc mã vào những vị trí cụ thể trong nhị phân, trên hầu hết nền tảng đó là các vùng gọi là section
- Rust cũng có thể dùng cùng cách tổ chức này thông qua thuộc tính
link_section - Nhiều linker cho phép lập trình viên cung cấp linker script, là tệp văn bản chỉ dẫn cho linker cách lắp ráp các object file
- Với linker script, cùng một tệp C có thể trở thành tệp thực thi Linux hoặc một khối assembly thô đặt ở boot sector của ổ đĩa cứng
- Linker script có thể định nghĩa các symbol ảo không tồn tại trong source file nhưng có thể dùng trong mã C để truy cập các con trỏ dữ liệu cơ sở của nhị phân đã nạp
- Trong linker script ví dụ,
_TEXT_START_và_TEXT_END_được định nghĩa để trỏ tới điểm đầu và điểm cuối của section.text - Dấu chấm trong
_TEXT_START_ = .;là location counter, được diễn giải thành giá trị gần với địa chỉ đầu ra hiện tại của nhị phân
Linker symbol
- Linker không đặt giá trị của symbol đầu/cuối thành con trỏ, mà đặt địa chỉ nơi một
staticcùng tên sẽ được đặt - Symbol đầu/cuối không phải con trỏ
*const Type, và không mang dữ liệu riêng mà chỉ có ý nghĩa về địa chỉ - Một section gồm dữ liệu nằm trong khoảng bao gồm symbol bắt đầu và loại trừ symbol kết thúc
- Nhiều linker hiện nay có khả năng tự động định nghĩa mọi biên section của tệp thực thi
- Trong toolchain GNU, với section tên
MY_SECTION, các symbol__start_MY_SECTIONvà__stop_MY_SECTIONsẽ được tự động định nghĩa - macOS có mẫu tương tự, tạo ra các symbol
section$startvàsection$endcho mỗi section - Trên GNU linker, các section không được nêu rõ trong linker script được gọi là orphan section
- Linker chỉ tự động định nghĩa các symbol tiền tố
_start·_stopkhi tên section tương thích với tên symbol C our_stringsthì hoạt động, nhưngour.stringshay.our_stringsthì không hoạt động theo cùng cách- Vì các symbol biên không có dữ liệu và chỉ địa chỉ là quan trọng, ví dụ dùng
MaybeUninit<()>để biểu diễn chúng - Rust stable hiện chưa có kiểu “opaque external type” lý tưởng, nên
MaybeUninitđóng vai trò thay thế - Việc tạo con trỏ
&raw consttới một mụcstaticluôn hợp lệ, vì vậy có thể lấy địa chỉ một cách an toàn mà không đọc giá trị link-sectiontrừu tượng hóa các chi tiết linker section kiểu này và chuyển chúng thành Rust slice để có thể dùng các phép toán slice tiêu chuẩn- Sức mạnh của link section nằm ở chỗ bất kỳ crate nào cung cấp mã cho nhị phân đều có thể gửi mục vào cùng một section, và linker sẽ gom tất cả lại ngay trước khi tạo nhị phân cuối cùng
Dependency injection
- Mẫu đăng ký dựa trên section hoạt động theo cùng nguyên lý với dependency injection
- Các framework như Dagger và Spring cũng dựa trên nguyên tắc rằng bên tiêu thụ dữ liệu đăng ký không nên bị ghép chặt với bên cung cấp
- Bên cung cấp đăng ký dữ liệu tại nơi định nghĩa, còn bên tiêu thụ đọc registry
- Trong dependency injection truyền thống, framework thường phải duyệt đồ thị module hoặc quét các class đã nạp khi khởi động để tìm provider và consumer
- Với linker section, linker thu thập dữ liệu provider khi nhị phân được tạo ra và giúp consumer dễ dàng đọc chúng
- Ví dụ đăng ký CLI subcommand là một trường hợp của mẫu này, dùng
link_section::sectionđể đăng ký subcommand - Turbopack dùng mẫu này cho string pool constant, cơ chế đăng ký serialize·deserialize, và đăng ký hàm biên dịch tăng dần của turbotask
- Một web server giả định cũng có thể dùng mẫu này để tự động thu thập route và middleware ở thời điểm build
Dùng section cho đăng ký
- Ưu điểm của công việc trước
mainlà sẽ không có thread nào chạy trừ khi được khởi động một cách tường minh - Trong môi trường này, trong nhiều trường hợp có thể tránh được độ phức tạp của lock hoặc các primitive đồng bộ hóa
- Có thể tách rõ vòng đời dữ liệu thành giai đoạn có thể ghi trước
mainvà giai đoạn bất biến saumain - Khi truy cập dữ liệu trong chương trình đang chạy, việc tránh lock và unlock có thể làm cấu trúc đơn giản hơn và hiệu quả hơn
- Ví dụ thu thập subcommand bằng
#[section], hàm khởi tạoconst, và structCliSubcommand - Các subcommand như
list,add,helpcó thể nằm ở bất kỳ đâu trong mã - Hàm
mainchỉ cần nhìn thấy định nghĩa sectionCLI_SUBCOMMANDSlà có thể dispatch động mà không cần biết tên hay vị trí của các subcommand đã đăng ký - Nếu không có subcommand nào được đăng ký, nó sẽ quay về subcommand mặc định; trong ví dụ,
helphoạt động như giá trị mặc định
Vượt ra ngoài dữ liệu bất biến
- Ví dụ trước giả định dữ liệu được liên kết là bất biến, nhưng cách tổ chức dữ liệu dựa trên linker cũng có thể dùng cho dữ liệu có thể thay đổi
- Tính khả biến của dữ liệu tĩnh toàn cục là vấn đề quen thuộc trong Rust, và có thể xử lý bằng các công cụ interior mutability như mutex hay kiểu nguyên tử
- Mutex và kiểu nguyên tử không đắt khi không có tranh chấp, nhưng cũng không hoàn toàn miễn phí
- Để thay đổi dữ liệu an toàn trong Rust, việc thay đổi phải diễn ra theo cách thread-safe, và không được có tham chiếu nào khác tới cùng dữ liệu trong khi tồn tại tham chiếu mutable
- Môi trường pre-main là đơn luồng nếu không chủ động khởi động thread, nên không cần thao tác nguyên tử
- Trong môi trường đơn luồng, quan hệ happens-before trong đó ghi xảy ra trước đọc về sau sẽ tự động được đảm bảo
- Việc thay đổi dữ liệu link section trước
maincó thể được truy cập an toàn mà không cần khóa từ bất kỳ thread nào về sau - Nếu chỉ tạo và kết thúc tham chiếu mutable trước
main, điều kiện không có tham chiếu khác trong lúc tồn tại tham chiếu mutable cũng được đáp ứng - Slice của link section là alias của các mục tĩnh bên trong section, nên các quy tắc aliasing áp dụng cho cả slice và các mục tĩnh
- Để thay đổi qua slice một cách an toàn, các mục tĩnh bắt buộc phải được đặt trong
UnsafeCell - Với mục tĩnh không bọc
UnsafeCell, LLVM có thể cache giá trị, sắp xếp lại hoặc đưa ra các giả định về dữ liệu - Bản thân
UnsafeCellkhông phảiSync, nên cần thêm một kiểu wrapper riêng - Ví dụ dùng
SyncUnsafeCellvàMaybeUninit<SyncUnsafeCell<...>>để tạo các symbol biên và các mục - Ví dụ string interning pool có thể sắp xếp được định nghĩa pool tại thời điểm link, sắp xếp slice ở đầu runtime, rồi sau đó tìm chuỗi bằng binary search
- Cách tự triển khai có nhiều boilerplate, nhưng với
ctorvàlink-sectioncó thể tạo cùng cấu trúc một cách gọn hơn bằngTypedMutableSectionvà constructor - Các mục của
TypedMutableSectionphải làconst, vì mã nội bộ sử dụng cách làm tương tự ví dụ triển khai thủ công
Lợi ích của mẫu link section
- Mẫu này tổng hợp các mục được gắn nhãn theo cách được đảm bảo, và đặt toàn bộ dữ liệu vào bộ nhớ liên tục đã được cấp trước
- Vị trí đăng ký có thể được phân tán ở bất kỳ đâu trong mã
- Có thể lấy được số lượng mục trong section như một giá trị được đảm bảo
- Link section không cần cấp phát riêng
- Nếu tạo cùng cấu trúc mà không dùng link section, cần cấp phát
HashMap,Vechoặc cấu trúc dữ liệu khác, rồi có thể phải đổi kích thước nhiều lần trong khi gom mục - Với cách thu thập truyền thống, phụ thuộc giữa module kiểu dùng chung, module đóng góp và module thu thập thường đan xen rất sâu
- Khi dùng link section, bộ thu thập có thể nằm ở bất cứ đâu và không cần quan tâm module nào đang đóng góp dữ liệu
scattered-collectcung cấp nhiều cấu trúc tương tự cấu trúc dữ liệu nhưng có hỗ trợ thời điểm liên kếtScattered*Slicelà các cấu trúc tương tựVeccung cấp slice, và có thể hỗ trợ sắp xếp tùy chọnScatteredMapvàScatteredSetlà các cấu trúc tương tựHashMap·HashSetcung cấp tra cứu khóa-giá trị dựa trên băm với khởi tạo pre-main tối thiểu
Khi nào không nên dùng cách này
- Tính toán ở thời điểm liên kết rất mạnh, nhưng không phải lúc nào cũng là công cụ phù hợp
- Thay vì cách làm ở thời điểm liên kết, có thể thu thập dữ liệu thủ công trong một crate có thể nhìn thấy từng crate muốn đóng góp dữ liệu
- Thu thập thủ công có thể bất tiện; nó đòi hỏi một crate thu thập có tham chiếu tới nhiều crate thay vì để bên đóng góp chỉ nhìn vào một điểm đóng góp duy nhất của crate lõi
- Việc loại bỏ dead code trở nên khó hơn
link-sectionvàlinkmegắn#[used]lên các mục, nên linker không thể loại bỏ dữ liệu không dùng- Với dữ liệu nhỏ như các atom chuỗi đã intern thì có thể không thành vấn đề, nhưng nếu intern các mảnh JSON·JavaScript thô hoặc cấu trúc dữ liệu lớn, rất dễ tích tụ nhiều dead code khó nhận ra
- Constructor pre-main có các ràng buộc riêng
- Constructor không được panic, và Rust không đảm bảo mọi hàm trong standard library đều khả dụng
- Thứ tự gọi các hàm khởi tạo trong cùng một mức ưu tiên không được đảm bảo và phụ thuộc mạnh vào nền tảng
- Có thể lách qua các giới hạn này bằng thiết kế cẩn thận, nhưng cách pre-main vẫn có thể không phù hợp vì tính tinh vi và độ khó khi debug
- Miri chưa hỗ trợ đầy đủ mọi constructor pre-main và cấu hình linker section
- Hiện tại Miri chỉ nhìn việc thực thi pre-main ở mức rất cơ bản và không mô hình hóa linker section
- Để kiểm thử undefined behavior, khuyến nghị dùng các LLVM sanitizer như ASan, TSan v.v.
- Mẫu inversion of control có thể khiến khó audit mọi vị trí đóng góp dữ liệu vào linker section
- Nhiều chương trình Rust được phân phối rộng rãi và sử dụng nhiều hiện đã phụ thuộc vào các tính năng pre-main như
ctor,link-section,inventory,linkme
Tóm tắt ngắn về WASM
- WASM không hỗ trợ linker section theo cách native do ảnh hưởng từ các lựa chọn trong quá khứ
- Chú thích
#[link_section]không đặt mục vào section mã thực sự mà đưa chúng vào custom section của WASM, nơi mã WASM tự thân không thể truy cập - Crate
linktimehỗ trợ WASM và cung cấp một cách mô phỏng để phương pháp này vẫn hoạt động trong nhị phân WASM - Có thể sẽ có các đề xuất trong tương lai để bổ sung hỗ trợ WASM phù hợp hơn
Kết luận
- Trước
main, có thể thực hiện nhiều công việc mang lại lợi ích đáng kể trong những trường hợp nhất định - Môi trường pre-main có thứ tự được kiểm soát rất cao và khả năng kiểm soát lớn, nên có thể làm nhiều việc một cách tự tin hơn mà không cần lock, kiểu nguyên tử hay các primitive đồng bộ hóa khác
- Linker section cho phép tổng hợp và đặt cạnh nhau dữ liệu liên quan một cách tùy ý trên toàn bộ nhị phân, đồng thời tránh được thứ tự phụ thuộc crate khó xử
- Trong nhiều trường hợp có thể tránh hoàn toàn việc cấp phát, từ đó giảm xa các vấn đề của allocator như phân mảnh do cấp phát lặp lại
- Các crate liên quan gồm
ctor,dtor,link-section,scattered-collect
1 bình luận
Ý kiến trên Lobste.rs
Apple dùng libSystem.dylib làm ranh giới ổn định ABI cho system call, còn Windows dòng NT thì đặt
ntdll.dlllàm ranh giới ổn định ABI thay vì chính system call: not syscallsTrên OpenBSD, có vẻ Go từng đặt một cờ metadata theo kiểu tắt cưỡng chế áp dụng bit NX để tránh chính sách kernel sẽ kill nếu cố gọi system call bên ngoài vùng ánh xạ
libcchỉ đọc mà loader đã thiết lậpTuy vậy, vì libSystem.dylib contains the functionality which would normally be
libc.soplus other things, nên ở khía cạnh đó nó cũng giống cách các hệ BSD coi “libc là ranh giới ổn định”Và kể từ Go 1.16, Go dùng libc để tuân theo chính sách system call của OpenBSD
Linux là trường hợp tương đối hiếm khi có số hiệu system call ổn định, vì nó không có cấu trúc kiểu “một mảnh kernel được nạp vào không gian địa chỉ tiến trình dưới dạng thư viện động và chia sẻ định nghĩa
enumsystem call không ổn định với mã chạy ở kernel mode” như các OS khác, và cũng vì Linux với glibc không được phát triển cùng nhau trong một kho mã như ở những nơi khácTrên Windows, runtime C còn đảm nhiệm việc phân tích chuỗi lệnh kiểu CP/M mà MS-DOS sao chép sang, và API tạo tiến trình con của Windows cũng thừa hưởng, để biến nó thành mảng
argvkiểu POSIXVì vậy tài liệu Python
subprocesscó mục Converting an argument sequence to a string on Windows, giải thích cách biến mảngargvthành chuỗi theo các quy tắc dấu ngoặc kép được đóng cứng trong runtime C của MS. Trình phân tích riêng của tiến trình con được gọi có thể hoạt động khác các quy tắc này nếu muốn_starttrên Linux cũng không chính xác là việc linker tự động chèn một symbol tên đó vào binary. Nếu binary định dạng ELF là executable chứ không phải library, trườnge_entrytrong header, tức offset0x18, sẽ chứa địa chỉ mà loader nhảy tới sau khi thiết lập bộ nhớ_startlà quy ước của GCC để chỉ đích màe_entrysẽ trỏ tới khi không dùng entry point do libc cung cấp, và tôi nhớ các công cụ như NASM cũng làm theo_WinMainCRTStartuptrên Windows cũng được loader tìm quaAddressOfEntryPointtrong PE header. Nó nằm ở offset0x0028tính từ đầu PE header, còn PE header này nằm sau MZ (DOS EXE) header và DOS StubNếu muốn tìm hiểu chi tiết PE header, Making the smallest Windows application và Tiny PE là tài liệu hay. Tiny PE thậm chí còn vi phạm đặc tả PE theo những cách Windows vẫn chấp nhận, ví dụ chồng lấn các phần mà OS không đọc hoặc nhét code vào những trường header không dùng tới. Đến mức này thì kích thước file tối thiểu mà Windows chấp nhận sẽ khác nhau tùy phiên bản Windows chạy nó
Với ELF executable cực nhỏ trên Linux, A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux cũng đáng xem
_start, trên các hệ a.out thì entry point nơi kernel đi vào executable theo truyền thống làstartđược khai báo trong csu/crt0. Ví dụ có 7th edition, VAX BSDTrình biên dịch C thời đó thêm
_trước global symbol, nên ở V7 có thể thấy nó khai báo_main, còn BSD thì khai báo tên assembly không trang trístartchostart()của CKhi ấy chương trình bắt đầu ngay từ đầu file, và lệnh gọi linker của
ccđược sắp xếp đểcrt0đứng ở đầu tiên.csulà mã khởi động C, còncrt0nghĩa là object hỗ trợ runtime C thứ 0Khó tìm hơn là System V với ELF đã hoạt động chính xác ra sao, nhưng
starthoặc_startvẫn tiếp tục được dùng làm entry point chương trình được khai báo trong csu/crt0Tôi chưa từng thực sự hiểu ELF đã thay đổi cách xử lý tiền tố
_thế nào, nhưng có lẽ vì vui mà người ta thêm một lớp nữa nênstartvì lý do nào đó thành_startMột cặp tương ứng rõ ràng là ELF dường như thêm
_end, tức đỉnh của BSS, tương ứng với vị trí màsbrk(0)sẽ trả về trước khimalloc()tạo heapmain” trong Rust, và nghĩ sẽ hay nếu có một bài tổng hợp nó là gì và vì sao nó hữu íchCũng có ý tưởng cho các bài tiếp theo, như cách tận dụng linker aggregation để tạo collection nhanh hơn, nhưng trước hết tôi muốn nghe phản hồi về chủ đề nhập môn này
no_stdvà đôi khi cả không cóalloc,mainchỉ là một hàm nữa mà thôi, còn khởi tạo phần lớn là việc của lập trình viênTôi có kha khá boilerplate tự viết trong codebase cho mục đích tương tự, nên khá tò mò các crate kiểu này ăn khớp với môi trường nhúng như thế nào