Bölüm 4:
Bir ELF Ustasına Dönüşmek GitHub'da düzenle

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).

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 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.

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 belleğe yüklenir. Dynamic linking’de ise kütüphanenin tamamı yüklenir. İlk bakışta bu daha verimsiz görünebilir, ama modern işletim sistemleri aynı kütüphaneyi belleğe bir kez yükleyip ardından kod bölümünü birden çok process arasında paylaşabildiği için aslında daha fazla alan tasarrufu sağlar. Durum bilgisi process’e özel olduğu için yalnızca kod paylaşılır, ama buna rağmen tasarruf onlarca hatta yüzlerce megabayt RAM seviyesine çıkabilir.

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 işletim sistemi 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 genelde kernel’ın içinde değil, ayrı bir user-space programda yürür. ELF dosyaları, program header table’daki PT_INTERP girdisinde hangi loader’ı kullanmak istediklerini belirtir; tipik örneklerden 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 belleğe yerleştirir; böylece programın statik verileri, BSS alanı ve makine kodu hazır olur. Program dynamic linked ise, kernel ayrıca ELF interpreter’ı (PT_INTERP) da yüklemek zorundadır; yani onun verileri, BSS alanı ve kodu da belleğe taşınır.

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.

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.

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