1 poin oleh GN⁺ 4 jam lalu | 1 komentar | Bagikan ke WhatsApp
  • Biner Rust melewati tahap inisialisasi runtime sebelum fn main(), dan pada tahap ini dilakukan pekerjaan seperti penanganan panic·unwinding serta konversi argumen program
  • Saat loader sistem operasi menyerahkan kontrol ke entry point, runtime C dan runtime Rust menjalankan fungsi inisialisasi, dan kode pre-main dapat ditempatkan dengan #[unsafe(link_section = "...")] serta metode konstruktor
  • Seksi linker mengumpulkan data yang diserahkan banyak crate ke satu tempat saat biner dibangun, dan link-section memungkinkan ini diperlakukan seperti slice Rust
  • Jika ctor dan link-section digunakan bersama, pola seperti pendaftaran subcommand CLI dan pengurutan pool interning string dapat disusun sebelum main, lalu dibaca tanpa lock setelahnya
  • Pendekatan ini menawarkan agregasi tanpa alokasi dan inversion of control, tetapi cakupan penerapannya harus dipilih dengan hati-hati karena sulitnya eliminasi dead code, batasan konstruktor, perbedaan platform, dan keterbatasan kompatibilitas Miri

Tahap sebelum main pada biner Rust

  • Semua biner Rust memiliki fn main(), tetapi alur eksekusi yang sebenarnya mencapai main hanya setelah melewati loader sistem operasi dan inisialisasi runtime
  • C memiliki runtime C yang dikenali sebagai libc, dan Rust memiliki runtime-nya sendiri melalui standard library, yang membangun abstraksi tingkat lebih tinggi di atas runtime C
  • Tujuan runtime adalah mengintegrasikan kode pengembang dengan sistem operasi platform
  • Pada tahap sebelum main, runtime C menyiapkan layanan runtime seperti alokasi, akses file, dan thread-local storage
  • Pada saat ini Rust menyiapkan penanganan panic dan unwinding, serta mengonversi argumen program gaya C ke antarmuka std::env::args
  • Tahap pre-main berjalan sebelum kode pengguna, bersifat single-threaded, dan lingkungannya dapat diprediksi urutannya, sehingga cocok untuk inisialisasi deterministik

Entry point

  • Biner dimulai ketika loader sistem operasi memuat biner ke memori, menyiapkan lingkungan, lalu menyerahkan kontrol
  • Di Linux, entry point disimpan dalam field e_entry pada header ELF, dan secara default linker menempatkan alamat simbol _start
  • Windows juga memiliki hook serupa, dan executable dimulai dari fungsi _WinMainCRTStartup
  • Bootstrapping runtime awal berupa pohon pemanggilan fungsi statis untuk hal-hal seperti inisialisasi file I/O dan inisialisasi allocator
  • Seiring runtime menjadi lebih kompleks, pohon pemanggilan inisialisasi statis juga membesar, dan biner memasukkan lebih banyak fitur runtime C yang mungkin diperlukan atau mungkin juga tidak
  • Ketika linker dapat menghapus kode yang tidak digunakan sebelum biner dibangun, dibutuhkan cara untuk menggantikan pohon pemanggilan inisialisasi statis
  • Pendekatan __attribute__((constructor)) milik GCC menempatkan daftar pointer fungsi inisialisasi pada area kontigu dalam biner, lalu runtime C mengiterasinya saat startup dan memanggilnya
  • Konstruktor kemudian dapat diberi prioritas, misalnya ketika inisialisasi malloc harus dilakukan sebelum buffered file I/O
  • Runtime glibc modern di Linux menyimpan pointer fungsi di .init_array, dan urutan eksekusi dapat ditentukan dengan sufiks numerik
  • Nilai prioritas 100 ke bawah dicadangkan untuk runtime itu sendiri, sehingga kode yang memakai runtime C harus menggunakan 101 ke atas
  • Di Rust, pointer fungsi inisialisasi dapat ditempatkan dengan atribut seperti #[used] dan #[unsafe(link_section = ".init_array.101")]

linktime: ctor, link-section, dan lainnya

  • Contoh bekerja di Linux dan beberapa BSD, tetapi tidak dirancang sebagai contoh lintas platform
  • macOS mendukung simbol start dan stop, tetapi namanya berbeda, sedangkan Windows tidak mendukung simbol start dan stop namun pada praktiknya memiliki aturan pengurutan seksi yang setara
  • ctor dan link-section adalah crate dari proyek linktime yang mengabstraksikan perbedaan antarplatform dan kerumitan kerja linker
  • inventory dan linkme adalah crate yang banyak dipakai dan dibangun di atas prinsip yang sama, tetapi ada keterbatasan dalam contoh ini
  • Crate ctor menangani boilerplate untuk mendaftarkan konstruktor secara lintas platform
  • Fungsi yang diberi atribut seperti #[ctor(unsafe, priority = 101)] akan dipanggil oleh runtime C setelah linker menatanya, meskipun tidak pernah dipanggil langsung di dalam kode

Seksi dan linker script

  • Kompiler dapat menempatkan data atau kode pada lokasi tertentu di dalam biner, yang di sebagian besar platform disebut seksi
  • Rust juga dapat menggunakan kemampuan pengorganisasian yang sama melalui atribut link_section
  • Banyak linker memungkinkan pengembang menyediakan linker script, yaitu file teks yang memberi tahu linker bagaimana file objek akan dirakit
  • Dengan linker script, satu file C bisa menjadi executable Linux, atau blok assembly mentah yang ditempatkan pada boot sector hard disk
  • Linker script dapat mendefinisikan simbol virtual yang tidak ada di file sumber, tetapi dapat dipakai untuk mengakses pointer data dasar dari biner yang dimuat dalam kode C
  • Dalam contoh linker script, _TEXT_START_ dan _TEXT_END_ didefinisikan untuk menunjuk ke awal dan akhir seksi .text
  • Titik pada _TEXT_START_ = .; berarti location counter yang ditafsirkan sebagai nilai yang mendekati alamat output saat ini dari biner

Simbol linker

  • Linker tidak menetapkan nilai simbol awal·akhir sebagai pointer, melainkan menetapkan alamat tempat static dengan nama yang sama berada
  • Simbol awal·akhir bukan pointer *const Type, dan tidak memiliki data sendiri, hanya bermakna sebagai alamat
  • Seksi terdiri dari data yang berada dalam rentang yang mencakup simbol awal dan mengecualikan simbol akhir
  • Banyak linker kini memiliki kemampuan untuk mendefinisikan batas semua seksi executable secara otomatis
  • Pada toolchain GNU, untuk seksi bernama MY_SECTION, simbol __start_MY_SECTION dan __stop_MY_SECTION akan didefinisikan otomatis
  • macOS memiliki pola serupa yang menyintesis simbol section$start dan section$end untuk setiap seksi
  • Pada linker GNU, seksi yang tidak disebutkan secara eksplisit dalam linker script disebut orphan section
  • Linker hanya otomatis mendefinisikan simbol berawalan _start·_stop jika nama seksi kompatibel dengan nama simbol C
  • our_strings bekerja, tetapi our.strings atau .our_strings tidak bekerja dengan cara yang sama
  • Karena simbol batas tidak memiliki data dan hanya alamatnya yang penting, contoh mengekspresikannya sebagai MaybeUninit<()>
  • Rust stable belum memiliki “opaque external type” ideal, sehingga MaybeUninit berperan sebagai pengganti
  • Membuat pointer &raw const terhadap item static selalu valid, sehingga alamatnya bisa diambil dengan aman tanpa membaca nilainya
  • link-section mengabstraksikan detail seksi linker ini dan mengubahnya menjadi slice Rust yang dapat memakai operasi slice standar
  • Kekuatan seksi link adalah bahwa crate mana pun yang menyumbang kode ke biner dapat mengirim item ke seksi yang sama, lalu linker mengumpulkannya tepat sebelum biner akhir dibuat

Dependency injection

  • Pola registrasi berbasis seksi bekerja dengan prinsip yang sama seperti dependency injection
  • Framework seperti Dagger dan Spring juga berdiri di atas prinsip bahwa konsumen data registrasi tidak boleh terikat dengan penyedianya
  • Penyedia mendaftarkan data di lokasi definisinya, dan konsumen membaca registry
  • Dalam dependency injection tradisional, framework sering harus menelusuri graph modul atau memindai class yang dimuat saat startup untuk menemukan penyedia dan konsumen
  • Dalam seksi linker, linker mengumpulkan data penyedia saat biner dibangun dan membuatnya mudah dibaca oleh konsumen
  • Contoh pendaftaran subcommand CLI adalah kasus pola ini dengan mendaftarkan subcommand melalui link_section::section
  • Turbopack memakai pola ini untuk konstanta string pool, mekanisme registrasi serialisasi·deserialisasi, dan pendaftaran fungsi kompilasi inkremental turbotask
  • Web server hipotetis juga dapat memakai pola ini untuk mengumpulkan route dan middleware secara otomatis pada waktu build

Menggunakan seksi untuk registrasi

  • Keuntungan pekerjaan sebelum main adalah thread tidak akan berjalan kecuali dimulai secara eksplisit
  • Dalam lingkungan ini, pada banyak kasus kerumitan lock atau primitive sinkronisasi dapat dihindari
  • Siklus hidup data dapat dibagi jelas menjadi tahap dapat-ditulis sebelum main dan tahap immutable setelah main
  • Menghindari pengambilan dan pelepasan lock saat mengakses data di program yang sedang berjalan dapat membuat struktur lebih sederhana dan lebih efisien
  • Contoh mengumpulkan subcommand dengan struct CliSubcommand, fungsi konstruktor const, dan #[section]
  • Subcommand seperti list, add, dan help dapat berada di mana saja dalam kode
  • Fungsi main dapat melakukan dispatch dinamis selama ia hanya melihat definisi seksi CLI_SUBCOMMANDS, tanpa perlu mengetahui nama dan lokasi subcommand yang terdaftar
  • Jika tidak ada subcommand yang terdaftar, ia kembali ke subcommand default, dan dalam contoh help bertindak sebagai default

Melampaui data immutable

  • Contoh sebelumnya mengasumsikan data yang ditautkan bersifat immutable, tetapi pengorganisasian data berbasis linker juga bisa dipakai untuk data mutable
  • Mutabilitas data statis global adalah masalah umum di Rust, dan bisa ditangani dengan alat interior mutability seperti mutex atau tipe atomik
  • Mutex dan tipe atomik tidak mahal saat tidak ada contention, tetapi juga tidak sepenuhnya gratis
  • Untuk mengubah data secara aman di Rust, perubahan harus dilakukan dengan aman terhadap thread, dan saat referensi mutable ada, tidak boleh ada referensi lain ke data yang sama
  • Lingkungan pre-main bersifat single-threaded selama thread tidak dimulai secara eksplisit, sehingga operasi atomik tidak diperlukan
  • Dalam lingkungan single-threaded, relasi happens-before antara perubahan dan pembacaan setelahnya terbentuk secara otomatis
  • Perubahan data seksi linker sebelum main kemudian dapat diakses dengan aman tanpa lock dari thread mana pun sesudahnya
  • Jika referensi mutable hanya dibuat dan ditutup sebelum main, maka syarat bahwa tidak ada referensi lain saat referensi mutable ada juga terpenuhi
  • Slice dari seksi linker adalah alias terhadap item statis di dalam seksi, sehingga aturan aliasing berlaku baik untuk slice maupun item statis
  • Agar bisa diubah dengan aman melalui slice, item statis harus ditempatkan di dalam UnsafeCell
  • Untuk item statis yang tidak dibungkus UnsafeCell, LLVM dapat melakukan cache nilai, reordering, atau membuat asumsi tentang data tersebut
  • UnsafeCell sendiri tidak Sync, sehingga dibutuhkan tipe pembungkus terpisah
  • Contoh menyusun simbol batas dan item dengan SyncUnsafeCell serta MaybeUninit<SyncUnsafeCell<...>>
  • Dalam contoh pool interning string yang dapat diurutkan, string pool didefinisikan pada waktu link, lalu slice diurutkan saat inisialisasi runtime awal agar string bisa dicari kemudian dengan binary search
  • Implementasi manual memiliki banyak boilerplate, tetapi dengan ctor dan link-section, struktur yang sama dapat dibuat lebih ringkas memakai TypedMutableSection dan konstruktor
  • Item pada TypedMutableSection harus berupa const, karena secara internal digunakan kode yang mirip dengan contoh implementasi manual

Manfaat pola seksi link

  • Pola ini mengagregasikan item yang diberi tag dengan cara yang terjamin dan menempatkan semua data pada memori kontigu yang telah dialokasikan sebelumnya
  • Lokasi registrasi dapat disebarkan ke mana saja dalam kode
  • Jumlah item dalam seksi dapat diperoleh sebagai nilai yang terjamin
  • Seksi link tidak memerlukan alokasi tambahan
  • Tanpa seksi link, struktur yang sama akan mengharuskan alokasi HashMap, Vec, atau struktur data lain, lalu melakukan resize berkali-kali saat item dikumpulkan
  • Dalam cara pengumpulan tradisional, dependensi antara modul tipe bersama, modul kontributor, dan modul pengumpul menjadi sangat saling terkait
  • Dengan seksi link, pengumpul dapat berada di mana saja dan tidak perlu peduli modul mana yang menyumbangkan data
  • scattered-collect menyediakan berbagai analog struktur data dengan dukungan waktu link
    • Scattered*Slice adalah berbagai struktur mirip Vec yang menyediakan slice, dengan dukungan pengurutan opsional
    • ScatteredMap dan ScatteredSet adalah struktur mirip HashMap·HashSet yang menyediakan lookup key-value berbasis hash dengan inisialisasi pre-main minimal

Kapan tidak sebaiknya memakai cara ini

  • Komputasi waktu link itu kuat, tetapi tidak selalu merupakan alat yang tepat
  • Sebagai pengganti pendekatan waktu link, data bisa dikumpulkan secara manual dalam crate yang dapat melihat setiap crate yang ingin menyumbangkan data
  • Pengumpulan manual bisa tidak nyaman, dan alih-alih kontributor melihat satu titik kontribusi di crate inti, dibutuhkan crate pengumpul yang memiliki banyak referensi crate
  • Eliminasi dead code menjadi lebih sulit
  • link-section dan linkme menambahkan #[used] pada item, sehingga linker tidak dapat menghapus data yang tidak digunakan
  • Pada data kecil seperti atom string hasil interning ini mungkin bukan masalah, tetapi jika yang di-intern adalah potongan JSON·JavaScript mentah atau struktur data besar, banyak dead code yang sulit diidentifikasi bisa menumpuk
  • Fungsi konstruktor pre-main memiliki batasan
  • Fungsi konstruktor tidak boleh memicu panic, dan Rust tidak menjamin semua fungsi standard library tersedia untuk digunakan
  • Urutan pemanggilan fungsi inisialisasi pada prioritas yang sama tidak dijamin dan sangat bergantung pada platform
  • Batasan ini bisa diakali dengan desain yang hati-hati, tetapi pendekatan pre-main dapat tetap menjadi pilihan yang salah karena sifatnya yang subtil dan sulit di-debug
  • Miri belum sepenuhnya mendukung semua konstruktor pre-main dan konfigurasi seksi linker
  • Saat ini Miri hanya memandang eksekusi pre-main secara sangat dasar, dan tidak memodelkan seksi linker
  • Untuk pengujian undefined behavior, sanitizer LLVM seperti ASan dan TSan direkomendasikan
  • Pola inversion of control dapat membuat semua lokasi yang menyumbangkan data ke seksi linker lebih sulit diaudit
  • Banyak program Rust yang didistribusikan luas dan banyak dipakai sudah bergantung pada fitur pre-main seperti ctor, link-section, inventory, dan linkme

Ringkasan singkat tentang WASM

  • WASM, akibat pengaruh pilihan masa lalu, tidak mendukung seksi linker secara native
  • Anotasi #[link_section] tidak menempatkan item ke seksi kode sungguhan, melainkan ke custom section WASM yang tidak dapat diakses dari kode WASM itu sendiri
  • Crate linktime mendukung WASM dan menyediakan solusi emulasi agar pendekatan ini tetap bekerja pada biner WASM
  • Cara menambahkan dukungan WASM yang tepat dapat diusulkan di masa depan

Kesimpulan

  • Sebelum main, banyak pekerjaan bisa dilakukan yang pada kasus tertentu memberi keuntungan besar
  • Lingkungan pre-main memiliki urutan yang sangat terkontrol dan tingkat kendali yang tinggi, sehingga banyak pekerjaan dapat dilakukan dengan lebih percaya diri tanpa lock, tipe atomik, atau primitive sinkronisasi lainnya
  • Seksi linker memungkinkan data terkait di seluruh biner diagregasikan secara bebas dan ditempatkan bersama, sambil menghindari urutan dependensi crate yang canggung
  • Dalam banyak kasus alokasi dapat dihindari sepenuhnya, sehingga menjauhkan program dari masalah allocator seperti fragmentasi akibat alokasi berulang
  • Crate terkait meliputi ctor, dtor, link-section, scattered-collect

1 komentar

 
GN⁺ 4 jam lalu
Pendapat Lobste.rs
  • Go merupakan pengecualian karena menghindari runtime C di sebagian besar platform, tetapi Apple mewajibkan runtime C untuk akses system call
    Apple memakai libSystem.dylib sebagai batas stabilitas ABI untuk system call, dan Windows keluarga NT menempatkan ntdll.dll sebagai batas stabilitas ABI, bukan system call itu sendiri: not syscalls
    Di OpenBSD, tampaknya Go pernah menetapkan flag metadata semacam mematikan penerapan paksa bit NX agar menghindari kebijakan kernel yang akan mematikan proses bila mencoba melakukan system call di luar pemetaan libc read-only yang disiapkan loader
    Namun libSystem.dylib contains the functionality which would normally be libc.so plus other things, jadi dalam hal itu serupa dengan pendekatan keluarga BSD di mana “libc adalah batas stabilitas”
    Selain itu, mulai Go 1.16, Go memakai libc agar mengikuti kebijakan system call OpenBSD
    Linux relatif jarang dalam hal ini karena memiliki nomor system call yang stabil, sebab tidak memakai struktur seperti OS lain, yaitu “potongan kernel yang dimuat sebagai pustaka dinamis ke ruang alamat proses dan berbagi definisi enum system call yang tidak stabil dengan kode mode kernel”, dan juga karena Linux dan glibc tidak dikembangkan bersama dalam repositori yang sama seperti di tempat lain
    Di Windows, runtime C juga bertugas mengurai string perintah bergaya CP/M menjadi array argv bergaya POSIX, warisan dari MS-DOS yang menyalinnya dan API pembuatan proses turunan Windows yang ikut mewarisinya
    Karena itu dokumentasi Python subprocess memiliki bagian Converting an argument sequence to a string on Windows, yang menjelaskan cara mengubah array argv menjadi string sesuai aturan tanda kutip yang tertanam di runtime MS C. Parser milik proses turunan yang dipanggil dapat saja sengaja berperilaku berbeda dari aturan ini
    _start di Linux juga, tepatnya, bukan berarti linker otomatis memasukkan simbol dengan nama itu ke dalam biner. Jika biner berformat ELF adalah executable dan bukan library, field e_entry di header, yaitu pada offset 0x18, berisi alamat yang akan dilompati loader setelah penyiapan memori
    _start adalah konvensi GCC untuk menentukan target yang ditunjuk e_entry saat tidak memakai entry point yang disediakan libc, dan sejauh yang saya ingat alat seperti NASM juga mengikutinya
    _WinMainCRTStartup di Windows juga ditemukan loader melalui AddressOfEntryPoint pada PE header. Lokasinya ada di offset 0x0028 dari awal PE header, dan PE header ini datang setelah header MZ (DOS EXE) dan DOS Stub
    Untuk mempelajari detail PE header, Making the smallest Windows application dan Tiny PE bagus untuk dibaca. Tiny PE bahkan melanggar spesifikasi PE dengan cara yang masih diterima Windows, misalnya menumpuk bagian yang tidak dibaca OS atau menaruh kode pada field header yang tidak digunakan. Jika sudah sampai tingkat ini, ukuran file minimum yang diterima Windows akan berbeda tergantung versi Windows yang menjalankannya
    Untuk executable ELF Linux yang sangat kecil, A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux juga layak dilihat
    • System call di FreeBSD dan NetBSD memiliki stabilitas ABI, sama seperti pustaka sistemnya
    • Terkait _start, pada sistem a.out kernel secara tradisional masuk ke executable melalui entry point start yang dideklarasikan di csu/crt0. Contohnya ada di edisi ke-7 dan VAX BSD
      Pada masa itu compiler C menambahkan _ di depan simbol global, sehingga di V7 Anda bisa melihat deklarasi _main, sedangkan di BSD nama assembly untuk start() milik C dideklarasikan sebagai start tanpa dekorasi
      Saat itu program dimulai dari awal file, dan pemanggilan linker oleh cc diatur agar crt0 berada paling depan. csu berarti kode startup C, sedangkan crt0 berarti objek dukungan runtime C ke-0
      Untuk cara kerjanya secara tepat di System V saat ELF muncul memang lebih sulit ditemukan, tetapi start atau _start tetap digunakan sebagai entry point program yang dideklarasikan di csu/crt0
      Saya belum pernah benar-benar memahami bagaimana ELF mengubah penanganan prefiks _, tetapi sepertinya mungkin ada satu lapisan tambahan yang dibuat sekadar untuk lucu, sehingga start entah mengapa menjadi _start
      Pasangan yang jelas tampaknya adalah ELF menambahkan _end, yang mengacu ke bagian atas BSS dan sesuai dengan lokasi yang akan dikembalikan sbrk(0) sebelum malloc() membangun heap
  • Saya tertarik pada kehidupan sebelum main di Rust, dan menurut saya akan bagus jika ada satu tulisan yang merangkum apa itu dan mengapa itu berguna
    Ada juga ide tulisan lanjutan seperti cara memanfaatkan agregasi linker untuk membuat collection yang lebih cepat, tetapi untuk saat ini saya ingin mendengar umpan balik tentang topik pengantar ini
    • Saya sudah cukup banyak mengerjakan Rust embedded, jadi di lingkungan no_std dan kadang bahkan tanpa alloc, main hanyalah satu fungsi lagi dan inisialisasi sebagian besar menjadi tanggung jawab pengembang
      Ada cukup banyak boilerplate buatan sendiri di codebase untuk tujuan serupa, jadi saya penasaran bagaimana crate-crate ini berinteraksi dengan lingkungan embedded