1 điểm bởi GN⁺ 4 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • 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-section cho phép xử lý chúng như Rust slice
  • Kết hợp ctorlink-section có thể thiết lập các mẫu như đăng ký CLI subcommand hay sắp xếp string interning pool trước main, 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ới main sau 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_entry củ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ụ malloc có thể cần được khởi tạo trước file I/O có buffer
  • Runtime glibc hiệ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]#[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 startstop nhưng dùng tên khác, còn Windows không hỗ trợ startstop nhưng có quy tắc sắp xếp section về thực chất là tương đương
  • ctorlink-section là các crate thuộc dự án linktime, 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 linker
  • inventorylinkme là 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 ctor xử 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__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 static cù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_SECTION__stop_MY_SECTION sẽ được tự động định nghĩa
  • macOS có mẫu tương tự, tạo ra các symbol section$startsection$end cho 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·_stop khi tên section tương thích với tên symbol C
  • our_strings thì hoạt động, nhưng our.strings hay .our_strings thì 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 const tới một mục static luô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-section trừ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ư DaggerSpring 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 main là 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 main và giai đoạn bất biến sau main
  • 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ạo const, và struct CliSubcommand
  • Các subcommand như list, add, help có thể nằm ở bất kỳ đâu trong mã
  • Hàm main chỉ cần nhìn thấy định nghĩa section CLI_SUBCOMMANDS là 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ụ, help hoạ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 main có 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 UnsafeCell không phải Sync, nên cần thêm một kiểu wrapper riêng
  • Ví dụ dùng SyncUnsafeCellMaybeUninit<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 ctorlink-section có thể tạo cùng cấu trúc một cách gọn hơn bằng TypedMutableSection và constructor
  • Các mục của TypedMutableSection phả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, Vec hoặ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-collect cung 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ết
    • Scattered*Slice là các cấu trúc tương tự Vec cung cấp slice, và có thể hỗ trợ sắp xếp tùy chọn
    • ScatteredMapScatteredSet là các cấu trúc tương tự HashMap·HashSet cung 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-sectionlinkme gắ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 linktime hỗ 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
  • Go là trường hợp khá ngoại lệ ở chỗ tránh runtime C trên hầu hết nền tảng, nhưng Apple lại yêu cầu runtime C để truy cập system call
    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.dll làm ranh giới ổn định ABI thay vì chính system call: not syscalls
    Trê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ạ libc chỉ đọc mà loader đã thiết lập
    Tuy vậy, vì libSystem.dylib contains the functionality which would normally be libc.so plus 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”
    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 enum system 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ác
    Trê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 argv kiểu POSIX
    Vì vậy tài liệu Python subprocess có mục Converting an argument sequence to a string on Windows, giải thích cách biến mảng argv thà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
    _start trê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ường e_entry trong header, tức offset 0x18, sẽ chứa địa chỉ mà loader nhảy tới sau khi thiết lập bộ nhớ
    _start là quy ước của GCC để chỉ đích mà e_entry sẽ 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
    _WinMainCRTStartup trên Windows cũng được loader tìm qua AddressOfEntryPoint trong PE header. Nó nằm ở offset 0x0028 tính từ đầu PE header, còn PE header này nằm sau MZ (DOS EXE) header và DOS Stub
    Nếu muốn tìm hiểu chi tiết PE header, Making the smallest Windows applicationTiny 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
    • System call của FreeBSD và NetBSD có độ ổn định ABI giống như các thư viện hệ thống
    • Về _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 BSD
      Trì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í start cho start() của C
      Khi ấ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. csu là mã khởi động C, còn crt0 nghĩa là object hỗ trợ runtime C thứ 0
      Khó tìm hơn là System V với ELF đã hoạt động chính xác ra sao, nhưng start hoặc _start vẫn tiếp tục được dùng làm entry point chương trình được khai báo trong csu/crt0
      Tô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ên start vì lý do nào đó thành _start
      Mộ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 khi malloc() tạo heap
  • Tôi quan tâm đến “cuộc sống trước main” 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 ích
    Cũ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
    • Tôi đã làm khá nhiều Rust nhúng, nên trong môi trường no_std và đôi khi cả không có alloc, main chỉ 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ên
      Tô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