Ana içeriğe geç

Bölüm 8: Bir ELF Ustasına Dönüşmek

Artık execve‘ı epey iyi anlıyoruz. Çoğu yolun sonunda kernel, çalıştırılacak makine kodunu içeren son programa ulaşır. Ama koda atlamadan önce genelde bir kurulum süreci gerekir; örneğin programın farklı bölümlerinin bellekte doğru yerlere yüklenmesi gerekir. Her programın farklı türde ve miktarda belleğe ihtiyacı olduğundan, bir programın nasıl hazırlanacağını tarif eden standart dosya formatlarına ihtiyaç duyarız. Linux pek çok formatı destekler ama açık ara en yaygın olanı ELF‘dir (Executable and Linkable Format).

Bu bölümde neyi çözüyoruz?

  • ELF dosyasının kernel’a “beni belleğe şöyle yerleştir” bilgisini nasıl verdiğini göreceğiz.
  • Program header ile section header arasındaki farkı ayıracağız.
  • Static/dynamic linking, GOT, PLT ve dynamic loader rollerini basit bir akışa bağlayacağız.

Kağıt üzerine çizim yapan bir işaretleyici. Bir elinde bir gnu'nun kafasını, diğer elinde bir Linux penguenini tutan bir büyücü elf meditasyon yaparken gösteriliyor. Elf, "Aslında Linux sadece kernel'dır, işletim sistemi değil..." diyerek söz kesiyor. Çizimin başlığı kırmızı kalemle yazılmış.

(Bu sevimli çizim için Nicky Case‘e teşekkürler.)

Bir not: elfler her yerde mi?

Linux’ta bir uygulama ya da komut satırı programı çalıştırdığında, bunun bir ELF binary olması çok olasıdır. macOS tarafında fiili format Mach-O‘dur. Mach-O da temelde ELF ile aynı işi yapar, sadece farklı biçimde düzenlenmiştir. Windows’ta ise .exe dosyaları, yine benzer kavramları farklı bir paketle sunan Portable Executable formatını kullanır.

Linux kernel’ında ELF binary’leri, diğer birçok handler’dan çok daha karmaşık olan ve binlerce satır koda sahip binfmt_elf handler’ı tarafından işlenir. Bu kod, ELF dosyasındaki ayrıntıları ayrıştırmaktan ve bunları süreci belleğe yerleştirip çalıştırmak için kullanmaktan sorumludur.

Binfmt handler’larını satır sayısına göre sıralamak için biraz command-line kung fu yaptım:

Kabuk oturumu
$ wc -l binfmt_* | sort -nr | sed 1d
    2181 binfmt_elf.c
    1658 binfmt_elf_fdpic.c
     944 binfmt_flat.c
     836 binfmt_misc.c
     158 binfmt_script.c
      64 binfmt_elf_test.c

Dosya Yapısı

binfmt_elf‘in ELF dosyalarını nasıl çalıştırdığına daha derin girmeden önce, dosya formatının kendisine bakalım. ELF dosyaları genelde dört ana parçadan oluşur:

ELF dosyasının genel yapısını gösteren bir diyagram. ELF Header, Program Header Table, Section Header Table ve Data bölümlerinden oluşuyor.

ELF Header

Her ELF dosyasının bir ELF header‘ı vardır. Bunun görevi, binary hakkında temel bilgileri taşımaktır:

ELF header her zaman dosyanın başında bulunur. Program header table ile section header table’ın dosya içinde nerede olduğunu bildirir. Bu tablolar da dosyanın başka yerlerinde duran veri bloklarına işaret eder.

Program Header Table

Program header table, binary’nin çalışma zamanında nasıl yükleneceğini ve yürütüleceğini anlatan bir dizi girdiden oluşur. Her girdinin, ne tür bilgi taşıdığını belirten bir tür alanı vardır. Örneğin PT_LOAD, belleğe yüklenmesi gereken veri segmentini ifade eder; PT_NOTE ise herhangi bir yere yüklenmesi gerekmeyen serbest biçimli bilgi notlarını temsil eder.

Sık kullanılan program header türlerini gösteren bir tablo: PT_LOAD, PT_NOTE, PT_DYNAMIC, PT_GNU_STACK, PT_GNU_RELRO ve PT_INTERP.

Her giriş, verisinin dosya içinde nerede olduğunu ve bazen belleğe nasıl taşınacağını belirtir:

Section Header Table

Section header table, section‘lar hakkında bilgi taşıyan bir dizi girdidir. Bunu, ELF dosyasındaki verileri gösteren bir harita gibi düşünebilirsin. Böylece debugger gibi araçlar, farklı veri bölgelerinin ne işe yaradığını anlayabilir.

İçinde ELF section isimleri yazan eski bir hazine haritası çizimi. Section header table'ın binary içindeki veriler için harita gibi davrandığını anlatıyor.

Örneğin program header table, belleğe birlikte yüklenecek geniş bir veri aralığı tanımlayabilir. Tek bir PT_LOAD girdisi hem kodu hem de global değişkenleri içerebilir. Programın çalışması için bunların ayrıca tek tek işaretlenmesi gerekmez; CPU entry point’ten başlar ve program ne zaman nereye erişmek istiyorsa oraya gider. Ama analiz yapmak isteyen bir debugger’ın her alanın tam olarak nerede başladığını ve bittiğini bilmesi gerekir; yoksa hello yazan bir metni kod sanıp çözmeye çalışabilir ve doğal olarak ortalık dağılır. İşte bu bilgi section header table’da tutulur.

Genelde ELF dosyalarında bulunsa da, section header table aslında isteğe bağlıdır. Tamamen kaldırılmış olsa bile ELF binary’leri düzgün çalışabilir. Kodlarının ne yaptığını zorlaştırmak isteyen geliştiriciler bazen section header table’ı bilerek siler ya da bozar; kötü biçimlendirilmiş ELF başlıklarıyla analizden kaçma diye bir dünya bile var.

Her section’ın bir adı, türü ve nasıl çözümlenip kullanılacağını anlatan bazı bayrakları vardır. Geleneksel isimler çoğu zaman nokta ile başlar. Sık görülen örnekler şunlardır:

Veri

Program ve section header girdilerinin tamamı, ister belleğe yüklenecek veri olsun ister program kodunun nerede durduğunu gösteren referans olsun, ELF dosyası içindeki veri bloklarına işaret eder. Bu parçaların tamamı ELF dosyasının veri alanında bulunur.

ELF dosyasındaki farklı tabloların veri bloğundaki alanlara nasıl referans verdiğini gösteren bir diyagram.

Kendi sisteminde dene: readelf -h /bin/ls, readelf -l /bin/ls, readelf -d /bin/ls | grep NEEDED, ldd /bin/ls

Linking’e Kısa Bir Bakış

Şimdi yeniden binfmt_elf koduna dönelim: kernel, program header table’daki iki tür girdiye özellikle dikkat eder.

PT_LOAD girdileri, .text ve .data gibi program verilerinin bellekte nereye yerleştirileceğini belirtir. Kernel, programı CPU’nun yürütebilmesi için bu girdileri okuyup ilgili verileri belleğe yükler.

Kernel’ın önem verdiği diğer program header türü ise PT_INTERP‘tir; bu alan, “dynamic linking runtime”ı ya da daha pratik adıyla dynamic loader’ı işaret eder.

Dynamic linking’den önce genel olarak “linking”den söz edelim. Programcılar, programlarını yeniden kullanılabilir kütüphaneler üzerine kurar; biraz önce adı geçen libc bunun klasik örneğidir. Kaynak kodunu executable binary’ye dönüştürürken linker adlı program, ihtiyaç duyulan library kodunu bulur ve bunları binary’ye ekler. Dış kodun doğrudan dağıtılan dosyaya dâhil edildiği bu yönteme static linking denir.

Ama bazı kütüphaneler aşırı yaygındır. Libc, sistemle konuşmanın standart yolu olduğu için neredeyse her programda vardır. Bilgisayardaki her programa ayrı bir libc kopyası gömmek hem alan israfıdır hem de bakım açısından kötüdür. Tek bir yerde güncelleme yapıp bunu her programa yansıtabilmek çok daha iyidir. Dynamic linking bu derdin çözümüdür.

Static linked bir programın, bar adlı bir kütüphaneden foo fonksiyonuna ihtiyacı varsa, binary kendi içinde foo‘nun bir kopyasını taşır. Dynamic linked durumda ise binary yalnızca “Benim bar kütüphanesindeki foo‘ya ihtiyacım var” diyen bir referans tutar. Program çalıştırıldığında bar kütüphanesinin sistemde yüklü olduğu varsayılır ve foo‘nun makine kodu gerektiğinde belleğe alınır. Sistemindeki bar güncellenirse, programın kendisini yeniden derlemeye gerek kalmadan bir sonraki çalıştırmada yeni kod kullanılabilir.

Static linking ile dynamic linking arasındaki farkı gösteren bir diyagram.

Gerçek Hayatta Dynamic Linking

Linux’ta bar gibi dynamic link edilebilen kütüphaneler genelde .so (Shared Object) uzantılı dosyalar hâlinde paketlenir. Bu .so dosyaları da programlar gibi ELF dosyalarıdır; ELF header’ın dosyanın executable mı yoksa library mi olduğunu belirten bir alan taşıdığını hatırlarsın. Ayrıca shared object’lerin section header table’ında, hangi sembollerin dışa açıldığını ve dynamic link edilebildiğini anlatan .dynsym adlı bir section bulunur.

Windows’ta bar gibi kütüphaneler .dll (dynamic link library) dosyaları olarak paketlenir. macOS ise .dylib (dynamically linked library) uzantısını kullanır. Bunlar, tıpkı macOS uygulamaları ve Windows .exe dosyaları gibi ELF’den biraz farklı biçimlendirilmiştir; ama temel fikir aynıdır.

Static linking ile dynamic linking arasındaki ilginç farklardan biri şudur: Static linking’de kütüphanenin yalnızca gerçekten kullanılan bölümleri binary’ye girer ve programın parçası olur. Dynamic linking’de ise paylaşımlı kütüphanenin yüklenebilir segmentleri sanal belleğe haritalanır; fiziksel RAM’e gelen sayfalar demand paging sayesinde çoğu zaman yalnızca erişildikçe doldurulur. İlk bakışta bu daha verimsiz görünebilir, ama modern işletim sistemleri aynı kütüphanenin read-only code ve read-only data sayfalarını birden çok process arasında paylaşabildiği için aslında daha fazla alan tasarrufu sağlayabilir. Writable data ise process’e özel olur veya COW benzeri mekanizmalarla ayrışır.

GOT ve PLT: Tembel Bağlamanın Sırrı

Dynamic linking’in çalışma zamanında nasıl işlediğini anlamak için iki kritik yapıyı bilmek gerekir: GOT (Global Offset Table) ve PLT (Procedure Linkage Table).

PLT, her paylaşımlı kütüphane fonksiyonu için bir “trambolin” görevi görür:

  1. Program printf çağırdığında, aslında PLT’deki bir stub’a atlar.
  2. İlk çağrıda PLT stub’ı, GOT’taki girişi kontrol eder — henüz çözümlenmemiştir.
  3. Dynamic linker’a (ld.so) geri döner. ld.so, printf‘in gerçek adresini bulur ve GOT’a yazar.
  4. Sonraki tüm printf çağrılarında PLT artık GOT’taki gerçek adrese gider — resolver maliyeti kalkar, yalnızca küçük bir dolaylı atlama maliyeti kalabilir.

Buna lazy binding (tembel bağlama) denir. Program başlarken tüm fonksiyon adreslerini çözmek yerine, sadece gerçekten çağrılan fonksiyonlar çözümlenir. Bu davranış her sistemde ve her binary’de açık olmak zorunda değildir; LD_BIND_NOW, linker bayrakları ve güvenlik sıkılaştırma ayarları binding zamanını program başlangıcına çekebilir.

GOT aynı zamanda global değişkenlerin paylaşımlı kütüphaneler arasında nasıl paylaşıldığını da yönetir. Her shared library’nin kendi GOT’u vardır ve bu tablo, PIC (Position Independent Code) ile birlikte çalışarak kütüphanenin bellekte herhangi bir adrese yüklenebilmesini sağlar.

Yürütme

Şimdi ELF dosyalarını çalıştıran kernel’a geri dönelim: Eğer yürütülen binary dynamic linked ise, işletim sistemi onun koduna doğrudan atlayamaz; çünkü ihtiyaç duyduğu kodun bir kısmı henüz yerinde değildir. Unutma: dynamic linked programlar, ihtiyaç duydukları library fonksiyonlarına yalnızca referans taşır.

Programı gerçekten başlatabilmek için dynamic linker (ld.so) hangi kütüphanelere ihtiyaç duyulduğunu bulmalı, onları yüklemeli, isim olarak duran referansları gerçek adreslere çözmeli ve sonra gerçek program kodunu başlatmalıdır. Bu iş, ELF formatının ayrıntılarıyla yoğun şekilde uğraşan karmaşık bir süreçtir; bu yüzden kernel’ın ana işi değildir. Kernel, PT_INTERP ile belirtilen interpreter’ı yeni adres alanına map eder ve ilk atlanacak entry point’i buna göre ayarlar; geri kalan dynamic linking işi user-space loader’da yürür. Tipik interpreter örneklerinden biri /lib64/ld-linux-x86-64.so.2‘dir.

Kernel, ELF header’ı okuyup program header table’ı taradıktan sonra yeni programın bellek düzenini hazırlayabilir. İlk iş olarak tüm PT_LOAD segmentlerini sanal belleğe yerleştirir; böylece programın statik verileri, BSS alanı ve makine kodu adreslenebilir olur. Program dynamic linked ise, kernel ayrıca ELF interpreter’ı (PT_INTERP) da map eder; yani dynamic loader’ın verileri, BSS alanı ve kodu da yeni process’in adres alanında görünür hâle gelir.

Sonrasında kernel, user space’e dönerken CPU’nun geri yükleyeceği instruction pointer’ı ayarlar. Executable dynamic linked ise, instruction pointer ELF interpreter’ın bellekteki giriş noktasına konur. Aksi takdirde doğrudan executable’ın entry point’ine ayarlanır.

Kernel artık syscall’dan dönmeye neredeyse hazırdır. Unutma, hâlâ execve içindeyiz. Program başlarken okuyabilsin diye argc, argv ve environment değişkenlerini stack’e yazar.

Kernel stack’e yalnızca argc/argv/envp değil, bir de auxiliary vector yazar. Bu vektör AT_PHDR (program header adresi), AT_ENTRY (entry point), AT_PAGESZ (sayfa boyutu), AT_HWCAP (CPU özellikleri) gibi sistem bilgilerini taşır.

Bir de register temizliği yapılır. Kernel, syscall işlenmeden önce user space register’larının değerlerini, dönüşte geri yüklenmek üzere stack’te saklar. User space’e dönmeden hemen önce bu alanı temizler.

Sonunda syscall biter ve kernel tekrar user space’e döner. Kayıtları geri yükler ve saklı instruction pointer’a atlar. Bu instruction pointer artık yeni programın ya da gerekiyorsa ELF interpreter’ın giriş noktasıdır; yani mevcut süreç fiilen değiştirilmiş olur.

Program aslında main()‘den başlamaz. Entry point _start‘tır; CRT (C Runtime) başlatma kodu __libc_start_main‘i çağırır, o da gerekli kurulumları yapıp main()‘e geçer — bu yüzden main()‘den önce global değişkenler ilklendirilir.


Peki kernel bu segmentleri belleğe yüklerken nasıl bir dünya kuruyor? Her program aynı sanal adresleri kullanıyor gibi görünüyor ama neden çakışmıyor? İşte tam da burada sanal bellek devreye giriyor. Bir sonraki bölümde, bilgisayarındaki bu “gizli çevirmeni” keşfedeceğiz: MMU, sayfa tabloları ve fiziksel RAM ile sanal adresler arasındaki sihirli köprüyü öğreneceğiz.

9. bölüme devam et: Bilgisayarındaki Çevirmen