1 puan yazan GN⁺ 4 시간 전 | 1 yorum | WhatsApp'ta paylaş
  • Rust ikilileri fn main() öncesinde bir çalışma zamanı başlatma aşamasından geçer; bu aşamada panic·unwinding işlemleri ile program argümanlarının dönüştürülmesi gibi işler yapılır
  • İşletim sistemi yükleyicisi denetimi entry point'e devrettiğinde C çalışma zamanı ve Rust çalışma zamanı başlatma işlevlerini çalıştırır; #[unsafe(link_section = "...")] ve constructor yaklaşımıyla pre-main kodu yerleştirilebilir
  • Linker section'lar, birden fazla crate'in sunduğu verileri ikili oluşturma anında tek bir yerde toplar; link-section ise bunu Rust slice'ı gibi ele almayı sağlar
  • ctor ile link-section birlikte kullanıldığında CLI subcommand kaydı, string interning pool sıralaması gibi kalıplar main'den önce kurulabilir ve sonrasında kilit olmadan okunabilir
  • Bu yaklaşım allocasyonsuz toplulaştırma ve kontrolün tersine çevrilmesini sağlar; ancak dead code elimination zorluğu, constructor kısıtları, platform farkları ve Miri uyumluluğu sınırları nedeniyle uygulama alanı dikkatle seçilmelidir

Rust ikililerinde main öncesi aşama

  • Tüm Rust ikililerinde fn main() bulunur, ancak gerçek yürütme akışı işletim sistemi yükleyicisi ve çalışma zamanı başlatmasından geçtikten sonra main'e ulaşır
  • C'de libc olarak tanınan bir C çalışma zamanı vardır; Rust ise standart kütüphane aracılığıyla kendi çalışma zamanına sahiptir ve C çalışma zamanının üzerinde daha yüksek seviyeli soyutlamalar kurar
  • Çalışma zamanının amacı geliştirici kodu ile platform işletim sistemini bütünleştirmektir
  • C çalışma zamanı, main öncesi aşamada allocation, dosya erişimi, thread-local storage gibi çalışma zamanı hizmetlerini yapılandırır
  • Rust bu noktada panic ve unwinding işlemlerini hazırlar, ayrıca C tarzı program argümanlarını std::env::args arayüzüne dönüştürür
  • Pre-main aşaması kullanıcı kodundan önce çalışır, tek thread'lidir ve sıralaması öngörülebilir bir ortam sunar; bu yüzden deterministik başlatma için uygundur

Entry point

  • Bir ikili, işletim sistemi yükleyicisi ikiliyi belleğe yükleyip ortamı kurduktan sonra denetimi devretmesiyle başlar
  • Linux'ta entry point ELF başlığındaki e_entry alanında saklanır ve varsayılan olarak linker _start adlı sembolün adresini yerleştirir
  • Windows'ta da benzer bir kanca vardır; yürütülebilir dosya _WinMainCRTStartup işlevinden başlar
  • İlk çalışma zamanı bootstrap süreci, dosya I/O başlatma ve allocator başlatma gibi statik işlev çağrılarından oluşan bir ağaçtı
  • Çalışma zamanı karmaşıklaştıkça bu statik başlatma çağrı ağacı da büyüdü ve ikililer, gerekebilir de gerekmeyebilir de olan daha fazla C çalışma zamanı özelliğini içermeye başladı
  • Linker kullanılmayan kodu ikili oluşturulmadan önce kaldırabilir hale gelince, statik başlatma çağrı ağacının yerini alacak bir yönteme ihtiyaç doğdu
  • GCC'nin __attribute__((constructor)) yaklaşımı, başlatma işlevi işaretçilerinin listesini ikili içinde bitişik bir alana yerleştiriyor ve C çalışma zamanı başlangıçta bunları dolaşıp çağırıyordu
  • Constructor'lara öncelik verilebilir hale geldi; örneğin buffered file I/O'dan önce malloc başlatması gerekebilir
  • Linux'un modern glibc çalışma zamanı, işlev işaretçilerini .init_array içinde tutar ve sayısal soneklerle çalışma sırası belirlenebilir
  • Öncelik değeri 100 ve altı çalışma zamanının kendisine ayrılmıştır; bu yüzden C çalışma zamanı kullanan kod 101 ve üstünü kullanmalıdır
  • Rust'ta #[used] ve #[unsafe(link_section = ".init_array.101")] gibi niteliklerle başlatma işlevi işaretçileri yerleştirilebilir

linktime: ctor, link-section vb.

  • Örnek Linux ve çeşitli BSD'lerde çalışır, ancak çapraz platform bir örnek olarak tasarlanmamıştır
  • macOS start ve stop sembollerini destekler ama adları farklıdır; Windows ise start ve stop sembollerini desteklemez, ancak fiilen eşdeğer section sıralama kurallarına sahiptir
  • ctor ve link-section, linktime projesinin crate'leridir ve platform farkları ile linker işi karmaşıklığını soyutlar
  • inventory ve linkme aynı ilke üzerine kurulmuş yaygın crate'lerdir, ancak örnek için bazı sınırlara sahiptir
  • ctor crate'i, constructor'ları çapraz platform şekilde kaydetmek için gereken boilerplate'i üstlenir
  • #[ctor(unsafe, priority = 101)] gibi bir nitelik eklenen işlev, kod içinde doğrudan çağrılmasa bile linker düzenlemesinden sonra C çalışma zamanı tarafından çağrılır

Section'lar ve linker script'leri

  • Derleyici, veri ya da kodu ikili içindeki belirli konumlara yerleştirebilir; çoğu platformda bu alanlara section denir
  • Rust da link_section niteliği aracılığıyla aynı düzenleme yeteneğini kullanabilir
  • Birçok linker, geliştiricinin linker script sağlamasına izin verir; bu metin dosyası object file'ların nasıl birleştirileceğini linker'a bildirir
  • Linker script kullanıldığında tek bir C dosyası Linux yürütülebiliri olabilir ya da sabit diskin boot sector'üne yazılan ham assembly bloğu haline gelebilir
  • Linker script'leri, kaynak dosyalarda bulunmayan ama C kodunda yüklenmiş ikilinin taban veri işaretçilerine erişmekte kullanılabilen sanal semboller tanımlayabilir
  • Örnek linker script'teki _TEXT_START_ ve _TEXT_END_, .text section'ının başlangıcını ve sonunu gösterecek şekilde tanımlanır
  • _TEXT_START_ = .; ifadesindeki nokta, ikilinin mevcut çıktı adresine yakın bir değer olarak yorumlanan konum sayacını ifade eder

Linker sembolleri

  • Linker, başlangıç ve bitiş sembollerinin değerini işaretçi olarak ayarlamaz; bunun yerine aynı adlı static öğenin yerleştirileceği adresi belirler
  • Başlangıç ve bitiş sembolleri *const Type işaretçileri değildir; kendi verileri yoktur, yalnızca adres anlamı taşırlar
  • Section'lar, başlangıç sembolünü içeren ve bitiş sembolünü dışlayan aralıktaki verilerden oluşur
  • Birçok linker, yürütülebilir dosyadaki tüm section sınırlarını otomatik tanımlama özelliği kazanmıştır
  • GNU toolchain'de MY_SECTION adlı bir section için __start_MY_SECTION ve __stop_MY_SECTION sembolleri otomatik tanımlanır
  • macOS, her section için section$start ve section$end sembollerini türeten benzer bir kalıba sahiptir
  • GNU linker'da linker script içinde açıkça belirtilmeyen section'lara orphan section denir
  • Linker, _start ve _stop önekli sembolleri yalnızca section adı C sembol adıyla uyumluysa otomatik tanımlar
  • our_strings çalışır, ancak our.strings ya da .our_strings aynı şekilde çalışmaz
  • Sınır sembollerinde veri yoktur ve yalnızca adres önemlidir; bu yüzden örnekte bunlar MaybeUninit<()> ile ifade edilir
  • Stable Rust'ta ideal olan “opaque external type” henüz uygulanmadığından, MaybeUninit bir vekil görevi görür
  • &raw const işaretçisi bir static öğe için üretmek her zaman geçerlidir; dolayısıyla değer okunmadan yalnızca adres güvenle alınabilir
  • link-section, bu linker section ayrıntılarını soyutlar ve standart slice işlemleri kullanılabilen bir Rust slice'ına dönüştürür
  • Linker section'ların gücü, ikiliye kod sağlayan herhangi bir crate'in aynı section'a öğe ekleyebilmesi ve linker'ın son ikili yazılmadan hemen önce bunların hepsini bir araya getirmesidir

Dependency injection

  • Section tabanlı kayıt kalıbı, dependency injection ile aynı ilke üzerinde çalışır
  • Dagger ve Spring gibi framework'ler de kayıt verisini tüketenin sağlayıcıya sıkı bağlı olmaması gerektiği ilkesine dayanır
  • Sağlayıcı, tanımlandığı yerde veriyi kaydeder; tüketici ise registry'yi okur
  • Geleneksel dependency injection'da framework çoğu zaman başlangıçta modül grafiğini dolaşmalı ya da yüklenmiş sınıfları tarayarak sağlayıcı ve tüketicileri bulmalıdır
  • Linker section'larda ise sağlayıcı verileri ikili oluşturulurken linker tarafından toplanır ve tüketicinin kolayca okuyabileceği hale getirilir
  • CLI subcommand kaydı örneği, link_section::section ile subcommand kaydeden bu kalıbın bir örneğidir
  • Turbopack bu kalıbı string pool sabitleri, serialization·deserialization kayıt mekanizmaları ve turbotask incremental compilation function kayıtları için kullanır
  • Varsayımsal bir web sunucusu da route ve middleware bileşenlerini build anında otomatik toplamak için bu kalıbı kullanabilir

Kayıt için section kullanımı

  • main öncesi çalışmanın avantajlarından biri, açıkça başlatılmadıkça thread'lerin çalışmamasıdır
  • Bu ortamda çoğu durumda lock veya senkronizasyon primitive'lerinin karmaşıklığından kaçınılabilir
  • Verinin yaşam döngüsü, main öncesindeki yazılabilir aşama ile main sonrasındaki değişmez aşama olarak net biçimde ayrılabilir
  • Çalışan programda veriye erişirken lock alma ve bırakmadan kaçınmak yapıyı basitleştirip verimliliği artırabilir
  • Örnek, CliSubcommand yapısı, const constructor işlevi ve #[section] ile subcommand toplar
  • list, add, help gibi subcommand'lar kodun herhangi bir yerinde bulunabilir
  • main işlevi yalnızca CLI_SUBCOMMANDS section tanımını görüyorsa, kayıtlı subcommand adlarını ve konumlarını bilmeden dinamik dispatch yapabilir
  • Kayıtlı subcommand yoksa varsayılan subcommand'a dönülür; örnekte varsayılan olarak help çalışır

Değişmez verinin ötesi

  • Önceki örnek bağlı verinin değişmez olduğunu varsayar, ancak linker tabanlı veri düzenleme değişebilir veri için de kullanılabilir
  • Global statik verinin değişebilirliği Rust'ta yaygın bir sorundur ve mutex ya da atomic türler gibi içsel değişebilirlik araçlarıyla çözülür
  • Mutex ve atomic türler rekabet olmadığında pahalı değildir, ama tamamen ücretsiz de değildir
  • Rust'ta veriyi güvenli biçimde değiştirmek için değişikliğin thread-safe şekilde yapılması ve mutable referans varken aynı veriye başka referans bulunmaması gerekir
  • Pre-main ortamı, thread'ler açıkça başlatılmadıkça tek thread'li olduğundan atomic işlemler gerekmez
  • Tek thread'li ortamda, değişikliğin daha sonraki okumalardan önce gerçekleştiğini belirten happens-before ilişkisi kendiliğinden sağlanır
  • main öncesinde linker section verisini değiştirmek, sonrasında herhangi bir thread'den locksuz biçimde güvenli erişim sağlar
  • Mutable referans yalnızca main öncesinde oluşturulup kapatılırsa, mutable referans varken başka referans olmaması koşulu da sağlanır
  • Linker section slice'ı, section içindeki statik öğelere alias olduğundan, aliasing kuralları hem slice'a hem de statik öğelere uygulanır
  • Slice üzerinden güvenli değişiklik yapmak için statik öğeler mutlaka UnsafeCell içine yerleştirilmelidir
  • UnsafeCell ile sarılmamış statik öğelerde LLVM değerleri önbelleğe alabilir, yeniden sıralayabilir ya da veri hakkında varsayımlarda bulunabilir
  • UnsafeCell kendi başına Sync değildir; bu yüzden ayrı bir wrapper type gerekir
  • Örnek, sınır sembollerini ve öğeleri kurmak için SyncUnsafeCell ve MaybeUninit<SyncUnsafeCell<...>> kullanır
  • Sıralanabilir string interning pool örneği, link anında string pool'u tanımlar ve çalışma zamanının başında slice'ı sıralayarak sonrasında binary search ile string bulur
  • Elle yapılan uygulama çok boilerplate içerir; ancak ctor ve link-section ile TypedMutableSection ve constructor'lar kullanılarak aynı yapı daha kısa kurulabilir
  • TypedMutableSection öğeleri const olmalıdır; çünkü içeride, elle yapılan örneğe benzer bir kod kullanılır

Linker section kalıbının avantajları

  • Bu kalıp, etiketlenmiş öğeleri garantili şekilde toplulaştırır ve tüm veriyi önceden ayrılmış bitişik belleğe yerleştirir
  • Kayıt noktaları kodun her yerine dağıtılabilir
  • Section içindeki öğe sayısı garantili olarak elde edilebilir
  • Linker section'lar ek allocation gerektirmez
  • Aynı yapı linker section olmadan kurulsa HashMap, Vec veya başka veri yapıları allocate edilmeli ve öğeler toplanırken birçok kez yeniden boyutlandırılmalıydı
  • Geleneksel toplama yaklaşımında ortak tür modülü, katkı modülleri ve toplama modülü arasındaki bağımlılıklar derin biçimde iç içe geçer
  • Linker section kullanıldığında toplayıcı herhangi bir yerde olabilir ve hangi modüllerin veri sağladığını umursamak zorunda kalmaz
  • scattered-collect, link zamanı desteği olan çeşitli veri yapısı benzerleri sunar
    • Scattered*Slice, slice sağlayan çeşitli Vec benzeri yapılardır ve isteğe bağlı sıralamayı destekler
    • ScatteredMap ve ScatteredSet, minimum pre-main başlatmayla hash tabanlı anahtar-değer sorgulaması sunan HashMap ve HashSet benzeri yapılardır

Bu yaklaşım ne zaman kullanılmamalı

  • Link zamanı hesaplama güçlüdür, ancak her zaman doğru araç değildir
  • Link zamanı yaklaşımı yerine, veri katkısı yapacak her crate'i görebilen bir crate içinde veriler elle toplanabilir
  • Elle toplama zahmetli olabilir; katkı yapanların tek bir merkezi katkı noktasını görmesi yerine, birçok crate referansı taşıyan bir toplama crate'i gerekir
  • Dead code elimination zorlaşır
  • link-section ve linkme, öğelere #[used] eklediğinden linker kullanılmayan veriyi kaldıramaz
  • Intern edilmiş string atomları gibi küçük verilerde bu sorun olmayabilir; ancak ham JSON·JavaScript parçaları veya büyük veri yapıları intern edilirse tespit edilmesi zor çok miktarda dead code birikebilir
  • Pre-main constructor işlevlerinin kısıtları vardır
  • Constructor işlevleri panic oluşturmamalıdır ve Rust, standart kütüphanedeki tüm işlevlerin kullanılabilir olduğunu garanti etmez
  • Aynı öncelik içindeki başlatma işlevlerinin çağrı sırası garanti edilmez ve büyük ölçüde platforma bağlıdır
  • Bu kısıtlar dikkatli tasarımla aşılabilir, ancak pre-main yaklaşımı ince detayları ve hata ayıklama zorluğu nedeniyle yine de doğru seçim olmayabilir
  • Miri tüm pre-main constructor'ları ve linker section yapılandırmalarını tam olarak desteklemez
  • Şu anda Miri, pre-main yürütmeyi çok temel düzeyde görür ve linker section'ları modellemez
  • Tanımsız davranış testleri için ASan, TSan gibi LLVM sanitizer'ları önerilir
  • Kontrolün tersine çevrilmesi kalıbı, linker section'a veri katkısı yapılan tüm noktaların denetlenmesini zorlaştırabilir
  • Geniş dağıtıma sahip ve yoğun kullanılan birçok Rust programı zaten ctor, link-section, inventory, linkme gibi pre-main özelliklere dayanır

WASM hakkında kısa not

  • WASM, geçmişteki tasarım tercihlerinin etkisiyle linker section'ları yerel olarak desteklemez
  • #[link_section] notasyonu öğeleri gerçek kod section'larına yerleştiremez; bunun yerine onları WASM kodunun kendisinden erişilemeyen WASM custom section'larına koyar
  • linktime crate'i WASM'i destekler ve yaklaşımın WASM ikililerinde de çalışmasını sağlayan emülasyon tabanlı bir çözüm sunar
  • Uygun WASM desteği eklemeye yönelik öneriler ileride gelebilir

Sonuç

  • main öncesinde, belirli durumlarda ciddi avantajlar sağlayan pek çok iş yapılabilir
  • Pre-main ortamı yüksek derecede kontrollü ve yönetilebilir bir sıralama sunduğundan, lock, atomic type ve diğer senkronizasyon primitive'leri olmadan da birçok işlem daha güvenle yapılabilir
  • Linker section'lar, ilgili veriyi tüm ikili boyunca serbestçe toplulaştırıp birlikte yerleştirmeyi sağlar ve garip crate bağımlılık sıralarından kaçınmaya yardımcı olur
  • Çoğu durumda allocation tamamen önlenebilir; böylece tekrar eden allocation'lardan kaynaklanan parçalanma gibi allocator sorunlarından uzak durulabilir
  • İlgili crate'ler arasında ctor, dtor, link-section, scattered-collect bulunur

1 yorum

 
GN⁺ 4 시간 전
Lobste.rs görüşleri
  • Go, çoğu platformda C çalışma zamanından kaçınması bakımından istisnai, ancak Apple sistem çağrısı erişimi için C çalışma zamanını zorunlu kılıyor
    Apple, sistem çağrılarında ABI kararlılığı sınırı olarak libSystem.dylib kullanıyor; NT tabanlı Windows ise sistem çağrıları yerine ntdll.dll’i ABI kararlılığı sınırı olarak kullanıyor: not syscalls
    OpenBSD’de Go’nun, yükleyicinin ayarladığı salt okunur libc eşlemesinin dışından sistem çağrısı denemesini çekirdeğin süreç sonlandırma politikasıyla engellemesini aşmak için, NX bitinin zorunlu uygulanmasını kapatan türden metadata bayrakları ayarladığı anlaşılıyor
    Ancak libSystem.dylib contains the functionality which would normally be libc.so plus other things; bu açıdan BSD ailesindeki “libc kararlılık sınırıdır” yaklaşımıyla aynı
    Ayrıca As of Go 1.16 itibarıyla Go, OpenBSD’nin sistem çağrısı politikasına uymak için libc kullanıyor
    Linux, kararlı sistem çağrısı numaralarına sahip olduğu için görece nadir bir örnek; çünkü diğer işletim sistemlerindeki gibi “süreç adres alanına dinamik kütüphane olarak yüklenen çekirdek parçasının, çekirdek modu koduyla kararsız sistem çağrısı enum tanımlarını paylaşması” şeklinde bir yapıya sahip değil ve Linux ile glibc de başka yerlerde olduğu gibi aynı depoda birlikte geliştirilmiyor
    Windows’ta C çalışma zamanı ayrıca, MS-DOS’tan kopyalanıp Windows’un alt süreç oluşturma API’sine de miras kalan CP/M tarzı komut dizgesini POSIX tarzı argv dizisine ayrıştırma işini üstleniyor
    Bu yüzden Python subprocess belgelerinde Converting an argument sequence to a string on Windows bölümü var; burada argv dizisinin, MS C çalışma zamanına gömülü tırnak kurallarına göre bir dizgeye nasıl dönüştürüldüğü anlatılıyor. Çağrılan alt sürecin kendi ayrıştırıcısı isterse bu kurallardan farklı davranabilir
    Linux’taki _start da tam olarak bağlayıcının bu isimdeki bir sembolü ikili dosyaya otomatik eklediği anlamına gelmiyor. ELF biçimindeki ikili dosya bir kütüphane değil de çalıştırılabilir dosyaysa, başlıktaki e_entry alanı, yani 0x18 ofseti, yükleyicinin bellek kurulumundan sonra atlayacağı adresi içeriyor
    _start, libc’nin sağladığı giriş noktasını kullanmadığınızda e_entry’nin işaret edeceği hedefi belirtmek için kullanılan bir GCC geleneği; NASM gibi araçlar da sanırım bunu izliyor
    Windows’taki _WinMainCRTStartup da yükleyici tarafından PE header içindeki AddressOfEntryPoint üzerinden bulunuyor. Bu alan, PE başlığının başlangıcına göre Offset 0x0028 konumunda yer alıyor; PE başlığının kendisi ise MZ (DOS EXE) başlığı ve DOS Stub’dan sonra geliyor
    PE başlığının ayrıntılarını öğrenmek için Making the smallest Windows application ve Tiny PE iyi kaynaklar. Tiny PE, Windows’un kabul ettiği şekillerde PE belirtimini ihlal ediyor; örneğin işletim sisteminin okumadığı bölümleri üst üste bindiriyor ya da kullanılmayan başlık alanlarına kod koyuyor. Bu seviyede, Windows’un kabul ettiği en küçük dosya boyutu kullanılan Windows sürümüne göre değişiyor
    Linux’taki çok küçük ELF çalıştırılabilir dosyaları için A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux de bakmaya değer
    • FreeBSD ve NetBSD’de sistem çağrıları, sistem kütüphaneleri gibi ABI kararlılığına sahip
    • _start ile ilgili olarak, a.out sistemlerinde çekirdeğin çalıştırılabilir dosyaya girdiği giriş noktası geleneksel olarak csu/crt0 içinde tanımlanan start idi. Örnek olarak 7th edition, VAX BSD verilebilir
      O dönemde C derleyicileri genel sembollerin başına _ eklediği için V7’nin _main tanımladığını, BSD’nin ise C’deki start() için assembly adını süssüz start olarak tanımladığını görebilirsiniz
      O zamanlar programlar dosyanın başlangıç noktasından başlardı ve cc’nin bağlayıcı çağrısı crt0’ın en başta yer almasını sağlayacak şekilde düzenlenirdi. csu, C başlangıç kodu; crt0 ise sıfırıncı C çalışma zamanı destek nesnesi anlamına gelir
      ELF’nin geldiği System V’de bunun tam olarak nasıl çalıştığını bulmak daha zor, ancak start veya _start, csu/crt0 içinde tanımlanan program giriş noktası olarak kullanılmaya devam etti
      ELF’nin _ önek işlemesini nasıl değiştirdiğini hiçbir zaman tam olarak anlamadım, ama galiba eğlence olsun diye bir katman daha eklenince start bir nedenle _start olmuş gibi görünüyor
      Daha net bir eş örnek olarak, ELF sanki _end de eklemiş gibi; bu BSS’nin üst sınırına karşılık geliyor ve malloc() yığını oluşturmadan önce sbrk(0)’ın döndüreceği konuma denk düşüyor
  • Rust’ta main öncesindeki yaşama ilgi duyuyordum; bunun ne olduğu ve neden faydalı olduğu üzerine tek bir yazı yazmanın iyi olacağını düşündüm
    Bağlayıcı toplamasını kullanarak daha hızlı koleksiyonlar oluşturma gibi devam yazısı fikirlerim de var, ama önce bu giriş düzeyi konu hakkında geri bildirim almak istiyorum
    • Uzun zamandır gömülü Rust ile uğraşıyorum; bu yüzden no_std ve bazen alloc bile olmayan ortamlarda main sadece başka bir fonksiyon oluyor ve ilklendirme büyük ölçüde geliştiricinin sorumluluğunda kalıyor
      Benzer amaçlarla kod tabanımda epey el yapımı tekrar kodu var; bu yüzden bu crate’lerin gömülü ortamla nasıl örtüştüğünü merak ediyorum